diff --git a/.github/agents/sage-github.agent.md b/.github/agents/sage-github.agent.md index 511e5fa..23f1006 100644 --- a/.github/agents/sage-github.agent.md +++ b/.github/agents/sage-github.agent.md @@ -1,6 +1,6 @@ --- description: 'Expert assistant for managing SAGE project GitHub Issues using sage-github-manager CLI tool' -tools: [] +tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'copilot-container-tools/*', 'pylance-mcp-server/*', 'todo', 'github.vscode-pull-request-github/copilotCodingAgent', 'github.vscode-pull-request-github/issue_fetch', 'github.vscode-pull-request-github/suggest-fix', 'github.vscode-pull-request-github/searchSyntax', 'github.vscode-pull-request-github/doSearch', 'github.vscode-pull-request-github/renderIssues', 'github.vscode-pull-request-github/activePullRequest', 'github.vscode-pull-request-github/openPullRequest', 'ms-python.python/getPythonEnvironmentInfo', 'ms-python.python/getPythonExecutableCommand', 'ms-python.python/installPythonPackage', 'ms-python.python/configurePythonEnvironment'] --- # SAGE GitHub Issues Management Agent diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ae9486b..1cc0ebd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -161,27 +161,32 @@ export GITHUB_REPO=SAGE # Download all SAGE issues github-manager download -# List issues with filters +# ✅ List issues with filters (IMPLEMENTED) github-manager list --state open --label bug github-manager list --assignee shuhao +github-manager list --milestone "v2.0" --sort created --limit 20 +github-manager list --label "priority:high" --label "bug" # Multiple labels # Show analytics for SAGE issues github-manager analytics # Shows: issue distribution, activity trends, contributor stats -# Export SAGE issues for reporting -github-manager export --format csv --output sage_issues.csv -github-manager export --format markdown --output ROADMAP.md - -# Batch operations on SAGE issues -github-manager batch close --label "wontfix" -github-manager batch label --add "priority:high" --filter "bug" -github-manager batch assign --assignee shuhao --label "p0" - -# AI-powered features -github-manager summarize --issue 123 -github-manager detect-duplicates -github-manager suggest-labels --issue 456 +# ✅ Export SAGE issues for reporting (IMPLEMENTED) +github-manager export sage_issues.csv --state open +github-manager export issues.json -f json --label bug +github-manager export roadmap.md -f markdown --template roadmap +github-manager export report.md -f markdown --template report --milestone "v2.0" + +# ✅ Batch operations on SAGE issues (IMPLEMENTED) +github-manager batch-close --label "wontfix" --dry-run # 预览模式 +github-manager batch-label --add "priority:high" --label "bug" +github-manager batch-assign -a shuhao --label "p0" +github-manager batch-milestone "v3.0" --state open + +# ❌ AI-powered features (NOT YET IMPLEMENTED) +# github-manager summarize --issue 123 +# github-manager detect-duplicates +# github-manager suggest-labels --issue 456 ``` ### Typical Workflows @@ -189,22 +194,22 @@ github-manager suggest-labels --issue 456 1. **Daily Issue Triage** ```bash github-manager download # Sync latest issues - github-manager list --state open --sort created --limit 20 + github-manager list --state open --sort created --limit 20 # ✅ WORKING github-manager analytics # Check issue health metrics ``` 2. **Sprint Planning** ```bash - github-manager list --milestone "v2.0" --state open - github-manager export --format markdown --filter milestone=v2.0 - github-manager batch assign --milestone "v2.0" + github-manager list --milestone "v2.0" --state open # ✅ WORKING + github-manager export sprint.md -f markdown --milestone "v2.0" --template roadmap # ✅ WORKING + # github-manager batch assign --milestone "v2.0" # ❌ NOT YET IMPLEMENTED ``` 3. **Release Preparation** ```bash - github-manager list --label "release-blocker" - github-manager batch label --add "resolved" --milestone "v1.5" - github-manager export --format csv --output release_report.csv + github-manager list --label "release-blocker" # ✅ WORKING + github-manager export release_report.csv --state closed --milestone "v1.5" # ✅ WORKING + # github-manager batch label --add "resolved" --milestone "v1.5" # ❌ NOT YET IMPLEMENTED ``` ## Testing & Quality diff --git a/.gitignore b/.gitignore index c8104c0..d16b00b 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ Thumbs.db *.swp *.swo *~ + +config.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 011e66e..df95bbf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,9 +39,8 @@ "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, "files.trimFinalNewlines": true, - "ruff.lint.run": "onSave", - "ruff.format.args": ["--config", "${workspaceFolder}/ruff.toml"], - "ruff.lint.args": ["--config", "${workspaceFolder}/ruff.toml"], + "ruff.configurationPreference": "filesystemFirst", + "ruff.path": ["ruff"], "mypy-type-checker.args": [ "--ignore-missing-imports", "--show-error-codes" diff --git a/README.md b/README.md index 06509b9..a2f8f8a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ github-manager status github-manager download --state all # Show statistics -github-manager stats +github-manager analytics # Team analysis github-manager team @@ -110,7 +110,7 @@ github-manager sync --direction both ```bash # Show statistics -github-manager stats +github-manager analytics # Team analysis github-manager team diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..5626f57 --- /dev/null +++ b/config.example.json @@ -0,0 +1,23 @@ +{ + "github": { + "owner": "your-org", + "repo": "your-repo", + "token_env_names": [ + "GITHUB_TOKEN", + "GH_TOKEN", + "GIT_TOKEN" + ] + }, + "paths": { + "base_dir": ".github-manager", + "workspace": "workspace", + "output": "output", + "metadata": "metadata" + }, + "settings": { + "sync_update_history": true, + "auto_backup": true, + "verbose_output": false + }, + "expertise_rules": {} +} diff --git a/CHANGELOG.md b/docs/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to docs/CHANGELOG.md diff --git a/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to docs/CONTRIBUTING.md diff --git a/DEVELOPMENT.md b/docs/DEVELOPMENT.md similarity index 99% rename from DEVELOPMENT.md rename to docs/DEVELOPMENT.md index ce54a7b..6840695 100644 --- a/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -146,13 +146,13 @@ from typing import Dict, List def load_config(path: str) -> Dict[str, Any]: """Load configuration from file. - + Args: path: Path to configuration file - + Returns: Configuration dictionary - + Raises: FileNotFoundError: If config file doesn't exist ValueError: If config file is invalid @@ -162,7 +162,7 @@ def load_config(path: str) -> Dict[str, Any]: f"Config file not found: {path}. " f"Please create it from .env.template" ) - + with open(path) as f: return yaml.safe_load(f) ``` @@ -228,10 +228,10 @@ from typing import List, Dict def new_feature_function(data: List[Dict]) -> str: """Process data for new feature. - + Args: data: List of dictionaries - + Returns: Processed result """ diff --git a/EXTRACTION_SUMMARY.md b/docs/EXTRACTION_SUMMARY.md similarity index 100% rename from EXTRACTION_SUMMARY.md rename to docs/EXTRACTION_SUMMARY.md diff --git a/docs/FAQ.md b/docs/FAQ.md index 7b78140..8282b4a 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -169,7 +169,7 @@ jobs: - run: github-manager download env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: github-manager stats + - run: github-manager analytics ``` ### Q: Can I export data to other formats? diff --git a/docs/IMPLEMENTATION_PROGRESS.md b/docs/IMPLEMENTATION_PROGRESS.md new file mode 100644 index 0000000..f3aa8d1 --- /dev/null +++ b/docs/IMPLEMENTATION_PROGRESS.md @@ -0,0 +1,266 @@ +# Implementation Progress Report + +**Project**: sage-github-manager +**Date**: 2026-01-03 +**Branch**: main-dev +**Repository**: intellistream/sage-github-manager + +--- + +## 📊 Feature Implementation Status + +### ✅ Completed Features + +#### 1. Configuration System (2026-01-03) + +**What**: Multi-layer configuration management with priority system + +**Implementation**: +- config.json (lowest priority) +- .env file (automatic loading) +- Environment variables +- Function parameters (highest priority) + +**Key Features**: +- Auto-loads .env from current dir, home dir (~/.github-manager/), or workspace +- Supports multiple token names (GITHUB_TOKEN, GH_TOKEN, GIT_TOKEN, SAGE_REPO_TOKEN) +- JSON config with defaults for owner/repo/token + +**Files**: +- `src/sage_github/config.py` - Config class with _load_config_json() and _load_env_file() +- `config.example.json` - Template for users + +**Testing**: ✅ Verified with `github-manager status` + +--- + +#### 2. List Command (2026-01-03) + +**What**: Flexible issue listing with filtering, sorting, and display + +**Usage**: +```bash +github-manager list --state open --label bug +github-manager list --assignee shuhao --milestone "v2.0" +github-manager list --label "priority:high" --label "bug" # Multiple labels +github-manager list --sort created --limit 20 +``` + +**Key Features**: +- Filter by: state (open/closed/all), labels (multiple), assignee, milestone, author +- Sort by: created, updated, comments, number +- Limit results +- Rich table output with color coding +- Shows issue stats (total, filtered, displayed) + +**Files**: +- `src/sage_github/helpers/filter_issues.py` - IssuesFilter class (205 lines) +- `src/sage_github/manager.py` - list_issues() method +- `src/sage_github/cli.py` - list command + +**Testing**: ✅ Tested with 1304 SAGE issues, all filters working + +--- + +#### 3. Export Command (2026-01-03) + +**What**: Export issues to CSV/JSON/Markdown formats + +**Usage**: +```bash +# CSV export +github-manager export sage_issues.csv --state open + +# JSON export +github-manager export issues.json -f json --label bug + +# Markdown export with templates +github-manager export roadmap.md -f markdown --template roadmap +github-manager export report.md -f markdown --template report --milestone "v2.0" +``` + +**Key Features**: + +**CSV Format**: +- All issue fields (number, title, state, author, labels, assignees, milestone, dates, comments, URL) +- Excel/Sheets compatible +- Example: 128 open issues → 11.08 KB + +**JSON Format**: +- Structured data with all metadata +- API integration friendly +- Nested labels/assignees arrays +- Example: 209 closed bugs → 63.34 KB + +**Markdown Format** (3 templates): +- **default**: Complete issue details with full descriptions +- **roadmap**: Issues grouped by milestone (perfect for planning) +- **report**: Concise bullet-point format + +**Filtering Support**: +- Reuses all list command filters (state, labels, assignee, milestone, author) +- Auto-adds file extension based on format +- Shows export stats and file info + +**Files**: +- `src/sage_github/helpers/export_issues.py` - IssuesExporter class (329 lines) +- `src/sage_github/manager.py` - export_issues() method +- `src/sage_github/cli.py` - export command (90 lines) + +**Testing**: +- ✅ CSV: 128 open issues → 11.08 KB +- ✅ JSON: 209 closed bugs → 63.34 KB +- ✅ Markdown roadmap: 128 open issues → 7.76 KB + +--- + +### 🔴 Pending Features + +#### 4. Batch Command (High Priority) + +**What**: Batch operations on multiple issues + +**Planned Usage**: +```bash +github-manager batch close --label "wontfix" +github-manager batch label --add "priority:high" --filter "bug" +github-manager batch assign --assignee shuhao --label "p0" +github-manager batch milestone --set "v3.0" --label "feature" +``` + +**Required Implementation**: +- Batch close with confirmation +- Batch add/remove labels +- Batch assign to users +- Batch milestone updates +- Dry-run mode (preview changes) +- Progress bar for operations + +**Estimated Effort**: 2-3 days + +--- + +#### 5. AI-Powered Features (Medium Priority) + +**What**: AI analysis of issues + +**Planned Commands**: +```bash +github-manager summarize --issue 123 +github-manager detect-duplicates +github-manager suggest-labels --issue 456 +``` + +**Required Implementation**: +- Integration with OpenAI/Claude API +- Issue summarization +- Duplicate detection using embeddings +- Smart label suggestions + +**Estimated Effort**: 3-5 days + +--- + +## 📈 Statistics + +### Code Metrics + +| Metric | Value | +|--------|-------| +| Total Python Files | 20 | +| Lines of Code | ~5,000 | +| Test Coverage | TBD (tests not yet written) | +| Helper Modules | 13 | +| CLI Commands | 8 (download, list, export, team, analytics, stats, show, web) | + +### Implementation Progress + +| Category | Completed | Total | Progress | +|----------|-----------|-------|----------| +| Core Features | 3/5 | 60% | 🟢🟢🟢⚪⚪ | +| Configuration | 1/1 | 100% | 🟢 | +| Documentation | 3/5 | 60% | 🟢🟢🟢⚪⚪ | + +--- + +## 🎯 Next Steps + +### Immediate (This Week) + +1. **Write Tests** for list and export commands + - Unit tests for IssuesFilter + - Unit tests for IssuesExporter + - Integration tests for CLI commands + - Target: 80% coverage + +2. **Implement Batch Command** + - Start with batch close (most common) + - Add batch label operations + - Add batch assign + - Include dry-run mode + +### Short-term (Next 2 Weeks) + +3. **Improve Documentation** + - Update README with export examples + - Create tutorial for typical workflows + - Add screenshots of Rich output + +4. **Code Quality** + - Add type hints to all functions + - Run mypy checks + - Add more docstrings + +### Long-term (Next Month) + +5. **AI-Powered Features** + - Research best AI API for issue analysis + - Implement summarization + - Add duplicate detection + +6. **Advanced Filtering** + - Search in issue body text + - Regex support for titles + - Date range filters + +--- + +## 📝 Lessons Learned + +### What Worked Well + +1. **Configuration Priority System**: Flexible yet simple, users can choose their preference +2. **Separate Filter Logic**: IssuesFilter class makes code testable and reusable +3. **Rich Library**: Beautiful terminal output improves UX significantly +4. **Modular Helpers**: Each helper module has a single responsibility + +### Challenges Overcome + +1. **Import Path Issues**: Fixed by ensuring consistent package naming (sage_github) +2. **Typer B008 Warnings**: Resolved by adding to ruff ignore list +3. **Data Loading**: Lazy loading with progress bars for better UX + +### Best Practices Established + +1. **No Fallback Logic**: Fail fast, fail loud - makes debugging easier +2. **Dependencies in pyproject.toml**: Never manual pip install +3. **Type Hints Everywhere**: Better IDE support and error catching +4. **Rich Progress Bars**: Users know when operations are running + +--- + +## 🔗 Related Documents + +- [MISSING_FEATURES.md](./MISSING_FEATURES.md) - Detailed feature tracking +- [QUICK_START.md](./QUICK_START.md) - Getting started guide +- [FAQ.md](./FAQ.md) - Common questions +- [Copilot Instructions](../.github/copilot-instructions.md) - Development guidelines + +--- + +## 📞 Contact + +For questions or contributions: +- GitHub: https://github.com/intellistream/sage-github-manager +- Issues: https://github.com/intellistream/sage-github-manager/issues +- Main Project: https://github.com/intellistream/SAGE diff --git a/MISSING_FEATURES.md b/docs/MISSING_FEATURES.md similarity index 71% rename from MISSING_FEATURES.md rename to docs/MISSING_FEATURES.md index 026097b..602b7b2 100644 --- a/MISSING_FEATURES.md +++ b/docs/MISSING_FEATURES.md @@ -36,7 +36,11 @@ github-manager list --label "priority:high" --assignee shuhao - `src/sage_github/helpers/filter_issues.py` - Create filter helper - `src/sage_github/manager.py` - Add `list_issues()` method -**Status**: 🔴 Not Started +**Status**: � Completed + +**Implementation Date**: 2026-01-03 + +**Notes**: Fully implemented with filtering (state, labels, assignee, milestone, author), sorting, and Rich table output. --- @@ -66,7 +70,16 @@ github-manager export --state open --label bug --format markdown - `src/sage_github/helpers/export_issues.py` - Create export helper - `src/sage_github/manager.py` - Add `export_issues()` method -**Status**: 🔴 Not Started +**Status**: 🟢 Completed + +**Implementation Date**: 2026-01-03 + +**Notes**: +- Supports CSV, JSON, and Markdown formats +- Three Markdown templates: default (detailed list), roadmap (milestone-grouped), report (concise) +- Full filtering support (state, labels, assignee, milestone, author) +- Auto-adds file extension based on format +- Shows file size and location after export --- @@ -77,19 +90,19 @@ github-manager export --state open --label bug --format markdown **Expected Usage**: ```bash # Close issues -github-manager batch close --label "wontfix" -github-manager batch close --filter "state=open" --milestone "old-sprint" +github-manager batch-close --label "wontfix" +github-manager batch-close --state open --milestone "old-sprint" # Add/remove labels -github-manager batch label --add "priority:high" --filter "label=bug" -github-manager batch label --remove "needs-review" --state closed +github-manager batch-label --add "priority:high" --label bug +github-manager batch-label --remove "needs-review" --state closed # Assign issues -github-manager batch assign --assignee shuhao --label "p0" -github-manager batch assign --assignee shuhao --milestone "v2.0" +github-manager batch-assign -a shuhao --label "p0" +github-manager batch-assign -a shuhao --milestone "v2.0" # Update milestone -github-manager batch milestone --set "v3.0" --label "feature" +github-manager batch-milestone "v3.0" --label "feature" ``` **Implementation Requirements**: @@ -107,7 +120,19 @@ github-manager batch milestone --set "v3.0" --label "feature" - `src/sage_github/helpers/batch_operations.py` - Create batch operations helper - `src/sage_github/manager.py` - Add batch methods -**Status**: 🔴 Not Started +**Status**: � Completed + +**Implementation Date**: 2026-01-03 + +**Notes**: +- Four batch commands: batch-close, batch-label, batch-assign, batch-milestone +- All commands support dry-run mode (--dry-run) for previewing changes +- Confirmation prompts before executing (can skip with --yes) +- Rich preview table showing affected issues (first 20) +- Progress bars for operations +- Full filtering support (state, labels, assignee, milestone, author) +- Direct GitHub REST API integration +- Tested with 128 SAGE open issues --- @@ -143,7 +168,19 @@ github-manager ai-analyze # Keep general analysis - `src/sage_github/cli.py` - Add dedicated commands - `src/sage_github/helpers/ai_analyzer.py` - Refactor for specific operations -**Status**: 🟡 Partially Implemented (generic `ai` command exists) +**Status**: � Completed + +**Implementation Date**: 2026-01-03 + +**Notes**: +- Three dedicated AI commands: + - `summarize`: Generate AI summaries for specific issues (requires OpenAI/Claude API) + - `detect-duplicates`: Find duplicate issues using text similarity (no API needed) + - `suggest-labels`: Recommend labels based on keywords (no API needed) +- Silent mode for non-API operations +- Rich table output for duplicates +- Smart keyword matching for label suggestions +- Tested with 1304 SAGE issues: 592 duplicate pairs detected at 0.8 threshold --- @@ -157,7 +194,16 @@ github-manager ai-analyze # Keep general analysis **Recommendation**: Either rename `stats` to `analytics` OR update documentation. -**Status**: 🔴 Naming inconsistency exists +**Status**: 🟢 Completed + +**Implementation Date**: 2026-01-03 + +**Solution**: Renamed `stats` to `analytics` +- Primary command: `github-manager analytics` +- Backward compatibility: `stats` still works but shows deprecation warning +- Updated all documentation (README, FAQ, QUICK_START, PROJECT_SUMMARY) +- Added comprehensive docstring with examples +- Command now hidden in help to encourage migration --- diff --git a/docs/PROJECT_SUMMARY.md b/docs/PROJECT_SUMMARY.md index c0daef7..5c6b073 100644 --- a/docs/PROJECT_SUMMARY.md +++ b/docs/PROJECT_SUMMARY.md @@ -120,7 +120,7 @@ pip install sage-github-manager |---------|-------------| | `github-manager status` | Show configuration and connection status | | `github-manager download` | Download issues from GitHub | -| `github-manager stats` | Generate statistics report | +| `github-manager analytics` | Generate statistics report | | `github-manager team` | Team management and analysis | | `github-manager ai` | AI-powered analysis | | `github-manager sync` | Sync with GitHub | diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 2b098b9..5e06f69 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -75,7 +75,7 @@ github-manager download ```bash # Generate and view statistics -github-manager stats +github-manager analytics # Output shows: # - Total issues count diff --git a/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md similarity index 100% rename from SETUP_GUIDE.md rename to docs/SETUP_GUIDE.md diff --git a/VSCODE_READY.md b/docs/VSCODE_READY.md similarity index 99% rename from VSCODE_READY.md rename to docs/VSCODE_READY.md index bbd83d8..753d658 100644 --- a/VSCODE_READY.md +++ b/docs/VSCODE_READY.md @@ -8,7 +8,7 @@ All development tools and configurations have been set up for the sage-github-ma - **File**: `.github-copilot-instructions.md` (8.9KB) - **Status**: ✅ Created and committed - **Auto-load**: Yes, VS Code will automatically detect this file -- **Content**: +- **Content**: - Project overview and structure - Critical coding principles (NO FALLBACK LOGIC) - Development workflow and best practices diff --git a/examples/advanced_usage.py b/examples/advanced_usage.py index 6fee341..0cab0a5 100644 --- a/examples/advanced_usage.py +++ b/examples/advanced_usage.py @@ -21,7 +21,7 @@ def main(): project_root=custom_root, github_owner="your-org", github_repo="your-repo" ) - print(f"\n⚙️ Custom Configuration:") + print("\n⚙️ Custom Configuration:") print(f" Project Root: {config.project_root}") print(f" Base Directory: {config.base_dir}") print(f" Workspace: {config.workspace_path}") diff --git a/examples/basic_usage.py b/examples/basic_usage.py index ddaba14..152c0ff 100644 --- a/examples/basic_usage.py +++ b/examples/basic_usage.py @@ -17,7 +17,7 @@ def main(): github_repo="SAGE", # Change to your repository ) - print(f"\n📋 Configuration:") + print("\n📋 Configuration:") print(f" Repository: {config.GITHUB_OWNER}/{config.GITHUB_REPO}") print(f" Project Root: {config.project_root}") print(f" Workspace: {config.workspace_path}") @@ -29,7 +29,7 @@ def main(): # Get repository info repo_info = config.get_repo_info() - print(f"\n📊 Repository Info:") + print("\n📊 Repository Info:") print(f" Full Name: {repo_info['full_name']}") print(f" Description: {repo_info.get('description', 'N/A')}") print(f" Stars: {repo_info['stargazers_count']}") diff --git a/pyproject.toml b/pyproject.toml index 78e1b3d..6dde9f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "requests>=2.31.0,<3.0.0", "jinja2>=3.1.0,<4.0.0", "pyyaml>=6.0.0,<7.0.0", + "python-dotenv>=1.0.0,<2.0.0", ] [project.optional-dependencies] diff --git a/ruff.toml b/ruff.toml index cb0050e..886e259 100644 --- a/ruff.toml +++ b/ruff.toml @@ -35,6 +35,7 @@ select = [ ignore = [ "E501", # line too long (handled by formatter) "B904", # raise-without-from-inside-except + "B008", # function-call-in-default-argument (needed for Typer) "C901", # complex-structure (too-complex function) "E402", # module-import-not-at-top-of-file ] diff --git a/src/sage_github/cli.py b/src/sage_github/cli.py index 2e3a9c5..efbb400 100644 --- a/src/sage_github/cli.py +++ b/src/sage_github/cli.py @@ -4,14 +4,14 @@ """ import os +from pathlib import Path import subprocess import sys -from pathlib import Path -import typer from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table +import typer from sage_github import IssuesConfig, IssuesManager from sage_github.helpers import IssuesDownloader @@ -112,9 +112,426 @@ def download( raise typer.Exit(1) -@app.command("stats") -def statistics(): - """显示Issues统计信息""" +@app.command("list") +def list_issues( + state: str = "open", + label: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + sort: str = "created", + reverse: bool = True, + limit: int | None = None, + show_body: bool = False, +): + """列出和过滤Issues + + 灵活的Issues列表和筛选功能,支持多种过滤条件组合。 + + 示例: + github-manager list # 列出所有开放的Issues + github-manager list --state all # 列出所有Issues + github-manager list --label bug --state open # 列出开放的bug + github-manager list --assignee shuhao # 列出分配给shuhao的Issues + github-manager list --milestone "v2.0" # 列出v2.0里程碑的Issues + github-manager list --sort comments --limit 10 # 列出评论最多的10个Issues + github-manager list --body # 显示Issue正文摘要 + + 参数: + --state: Issue状态 (all, open, closed), 默认: open + --label, -l: 按标签过滤 (可多次使用) + --assignee, -a: 按负责人过滤 + --milestone, -m: 按里程碑过滤 + --author: 按创建者过滤 + --sort: 排序字段 (created, updated, comments, number), 默认: created + --reverse: 降序排列, 默认: True + --limit, -n: 限制显示数量 + --body, -b: 显示Issue正文摘要 + """ + console.print(f"📋 [bold blue]Issues列表 (状态: {state})[/bold blue]") + + manager = IssuesManager() + + # 获取过滤后的Issues + issues = manager.list_issues( + state=state, + labels=label if label else None, + assignee=assignee, + milestone=milestone, + author=author, + sort_by=sort, + reverse=reverse, + limit=limit, + ) + + if not issues: + console.print("📭 [yellow]没有找到符合条件的Issues[/yellow]") + return + + # 创建表格显示 + table = Table(title=f"找到 {len(issues)} 个Issues") + table.add_column("#", style="cyan", width=6) + table.add_column("标题", style="white", no_wrap=False) + table.add_column("状态", style="green", width=8) + table.add_column("标签", style="yellow", width=20) + table.add_column("负责人", style="blue", width=12) + + if show_body: + table.add_column("摘要", style="dim", width=30) + + for issue in issues: + # 提取数据 + number = str(issue.get("number", "N/A")) + title = issue.get("title", "未知")[:60] + state_value = issue.get("state", "open") + state_emoji = "🟢" if state_value == "open" else "🔴" + + # 标签 + labels = issue.get("labels", []) + label_names = [label["name"] if isinstance(label, dict) else label for label in labels] + labels_str = ", ".join(label_names[:3]) # 最多显示3个标签 + if len(label_names) > 3: + labels_str += "..." + + # 负责人 + assignees = issue.get("assignees", []) + assignee_names = [a["login"] if isinstance(a, dict) else a for a in assignees] + assignees_str = ", ".join(assignee_names[:2]) if assignees else "未分配" + if len(assignee_names) > 2: + assignees_str += "..." + + row = [ + number, + title, + f"{state_emoji} {state_value}", + labels_str or "-", + assignees_str, + ] + + if show_body: + body = issue.get("body", "") + summary = body[:50].replace("\n", " ") if body else "-" + if len(body) > 50: + summary += "..." + row.append(summary) + + table.add_row(*row) + + console.print(table) + + # 显示过滤条件摘要 + filters_applied = [] + if state != "all": + filters_applied.append(f"状态={state}") + if label: + filters_applied.append(f"标签={', '.join(label)}") + if assignee is not None: + filters_applied.append(f"负责人={assignee or '未分配'}") + if milestone is not None: + filters_applied.append(f"里程碑={milestone or '无'}") + if author: + filters_applied.append(f"创建者={author}") + + if filters_applied: + console.print(f"\n🔍 过滤条件: {' | '.join(filters_applied)}") + + +@app.command("export") +def export_issues( + output: str = typer.Argument(..., help="输出文件路径"), + format: str = typer.Option("csv", "--format", "-f", help="导出格式: csv, json, markdown"), + state: str = typer.Option("all", help="Issue状态: all, open, closed"), + label: list[str] = typer.Option([], "--label", "-l", help="按标签过滤"), + assignee: str | None = typer.Option(None, "--assignee", "-a", help="按负责人过滤"), + milestone: str | None = typer.Option(None, "--milestone", "-m", help="按里程碑过滤"), + author: str | None = typer.Option(None, "--author", help="按创建者过滤"), + template: str = typer.Option("default", help="Markdown模板: default, roadmap, report"), +): + """导出Issues到文件 + + 支持多种导出格式和过滤条件,方便生成报告和分析。 + + 示例: + github-manager export issues.csv # 导出所有Issues到CSV + github-manager export issues.json -f json # 导出为JSON + github-manager export roadmap.md -f markdown # 导出为Markdown + github-manager export open_bugs.csv --state open --label bug + github-manager export v2.0.md -f markdown --milestone "v2.0" --template roadmap + + 支持的格式: + - csv: 适合在Excel/Sheets中分析 + - json: 适合程序处理和API集成 + - markdown: 适合文档和报告 + + Markdown模板: + - default: 完整的Issue详情列表 + - roadmap: 按里程碑分组的路线图 + - report: 简洁的报告格式 + """ + console.print(f"📤 [bold blue]导出Issues (格式: {format})[/bold blue]") + + manager = IssuesManager() + output_path = Path(output) + + # 自动添加扩展名 + if not output_path.suffix: + if format == "csv": + output_path = output_path.with_suffix(".csv") + elif format == "json": + output_path = output_path.with_suffix(".json") + elif format == "markdown": + output_path = output_path.with_suffix(".md") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("导出中...", total=None) + + success = manager.export_issues( + output_path=output_path, + format=format, + state=state, + labels=label if label else [], + assignee=assignee, + milestone=milestone, + author=author, + template=template, + ) + + progress.update(task, completed=True) + + if not success: + console.print("❌ [red]导出失败[/red]") + raise typer.Exit(1) + + # 显示导出信息 + console.print(f"\n📁 [green]文件位置[/green]: {output_path.absolute()}") + console.print(f"📊 [green]文件大小[/green]: {output_path.stat().st_size / 1024:.2f} KB") + + # 显示过滤条件 + filters_applied = [] + if state != "all": + filters_applied.append(f"状态={state}") + if label: + filters_applied.append(f"标签={', '.join(label)}") + if assignee is not None: + filters_applied.append(f"负责人={assignee or '未分配'}") + if milestone is not None: + filters_applied.append(f"里程碑={milestone or '无'}") + if author: + filters_applied.append(f"创建者={author}") + + if filters_applied: + console.print(f"\n🔍 应用的过滤条件: {' | '.join(filters_applied)}") + + +@app.command("batch-close") +def batch_close( + state: str = typer.Option("open", help="Issue状态: all, open, closed"), + label: list[str] = typer.Option([], "--label", "-l", help="按标签过滤"), + assignee: str | None = typer.Option(None, "--assignee", "-a", help="按负责人过滤"), + milestone: str | None = typer.Option(None, "--milestone", "-m", help="按里程碑过滤"), + author: str | None = typer.Option(None, "--author", help="按创建者过滤"), + dry_run: bool = typer.Option(False, "--dry-run", help="预览模式(不实际执行)"), + yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示"), +): + """批量关闭Issues + + 根据过滤条件批量关闭匹配的Issues。 + + 示例: + github-manager batch-close --label wontfix # 关闭所有wontfix标签的Issues + github-manager batch-close --state open --milestone "v1.0" # 关闭v1.0里程碑的所有打开Issues + github-manager batch-close --dry-run --label bug # 预览要关闭的bug Issues + github-manager batch-close --assignee shuhao --yes # 无需确认关闭shuhao的Issues + + ⚠️ 危险操作: 批量关闭Issues无法撤销,建议先使用 --dry-run 预览 + """ + console.print("🔒 [bold red]批量关闭Issues[/bold red]") + + manager = IssuesManager() + + result = manager.batch_close( + state=state, + labels=label if label else None, + assignee=assignee, + milestone=milestone, + author=author, + dry_run=dry_run, + auto_confirm=yes, + ) + + if result["skipped"] == result["total"] and not dry_run: + raise typer.Exit(1) + + +@app.command("batch-label") +def batch_label( + add: list[str] = typer.Option([], "--add", help="要添加的标签"), + remove: list[str] = typer.Option([], "--remove", help="要移除的标签"), + state: str = typer.Option("all", help="Issue状态: all, open, closed"), + label: list[str] = typer.Option([], "--label", "-l", help="按标签过滤"), + assignee: str | None = typer.Option(None, "--assignee", "-a", help="按负责人过滤"), + milestone: str | None = typer.Option(None, "--milestone", "-m", help="按里程碑过滤"), + author: str | None = typer.Option(None, "--author", help="按创建者过滤"), + dry_run: bool = typer.Option(False, "--dry-run", help="预览模式(不实际执行)"), + yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示"), +): + """批量管理标签 + + 根据过滤条件批量添加或移除标签。 + + 示例: + github-manager batch-label --add "priority:high" --label bug # 为所有bug添加高优先级 + github-manager batch-label --remove "needs-review" --state closed # 从已关闭Issues移除待审核标签 + github-manager batch-label --add "v2.0" --milestone "v2.0" # 为v2.0里程碑添加标签 + github-manager batch-label --add "urgent" --assignee shuhao --yes # 无需确认添加urgent标签 + + 💡 提示: 可以同时使用 --add 和 --remove 来执行多个操作 + """ + if not add and not remove: + console.print("❌ [red]请指定要添加(--add)或移除(--remove)的标签[/red]") + raise typer.Exit(1) + + manager = IssuesManager() + + # 执行添加标签 + if add: + console.print(f"🏷️ [bold blue]批量添加标签: {', '.join(add)}[/bold blue]") + result_add = manager.batch_add_labels( + add_labels=add, + state=state, + labels=label if label else None, + assignee=assignee, + milestone=milestone, + author=author, + dry_run=dry_run, + auto_confirm=yes, + ) + if result_add["skipped"] == result_add["total"] and not dry_run: + raise typer.Exit(1) + + # 执行移除标签 + if remove: + console.print(f"🏷️ [bold blue]批量移除标签: {', '.join(remove)}[/bold blue]") + result_remove = manager.batch_remove_labels( + remove_labels=remove, + state=state, + labels=label if label else None, + assignee=assignee, + milestone=milestone, + author=author, + dry_run=dry_run, + auto_confirm=yes, + ) + if result_remove["skipped"] == result_remove["total"] and not dry_run: + raise typer.Exit(1) + + +@app.command("batch-assign") +def batch_assign( + assignee: list[str] = typer.Option([], "--assignee", "-a", help="负责人(可多个)"), + state: str = typer.Option("all", help="Issue状态: all, open, closed"), + label: list[str] = typer.Option([], "--label", "-l", help="按标签过滤"), + milestone: str | None = typer.Option(None, "--milestone", "-m", help="按里程碑过滤"), + author: str | None = typer.Option(None, "--author", help="按创建者过滤"), + dry_run: bool = typer.Option(False, "--dry-run", help="预览模式(不实际执行)"), + yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示"), +): + """批量分配Issues + + 根据过滤条件批量分配Issues给指定负责人。 + + 示例: + github-manager batch-assign -a shuhao --label "priority:high" # 分配高优先级Issues + github-manager batch-assign -a alice -a bob --milestone "v2.0" # 分配给多人 + github-manager batch-assign -a shuhao --state open --dry-run # 预览分配 + github-manager batch-assign -a team-lead --label bug --yes # 无需确认分配bugs + + 💡 提示: 使用多个 -a 可以分配给多个负责人 + """ + if not assignee: + console.print("❌ [red]请指定负责人(--assignee 或 -a)[/red]") + raise typer.Exit(1) + + console.print(f"👥 [bold blue]批量分配给: {', '.join(assignee)}[/bold blue]") + + manager = IssuesManager() + + result = manager.batch_assign( + assignees=assignee, + state=state, + labels=label if label else None, + milestone=milestone, + author=author, + dry_run=dry_run, + auto_confirm=yes, + ) + + if result["skipped"] == result["total"] and not dry_run: + raise typer.Exit(1) + + +@app.command("batch-milestone") +def batch_milestone( + milestone: str = typer.Argument(..., help="要设置的里程碑名称"), + state: str = typer.Option("all", help="Issue状态: all, open, closed"), + label: list[str] = typer.Option([], "--label", "-l", help="按标签过滤"), + assignee: str | None = typer.Option(None, "--assignee", "-a", help="按负责人过滤"), + current_milestone: str | None = typer.Option( + None, "--current-milestone", help="按当前里程碑过滤" + ), + author: str | None = typer.Option(None, "--author", help="按创建者过滤"), + dry_run: bool = typer.Option(False, "--dry-run", help="预览模式(不实际执行)"), + yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认提示"), +): + """批量设置里程碑 + + 根据过滤条件批量设置Issues的里程碑。 + + 示例: + github-manager batch-milestone "v2.0" --label feature # 将所有feature设置到v2.0 + github-manager batch-milestone "v3.0" --current-milestone "v2.0" # 将v2.0迁移到v3.0 + github-manager batch-milestone "Sprint 5" --state open --dry-run # 预览设置 + github-manager batch-milestone "Q1-2026" --assignee shuhao --yes # 无需确认设置 + + 💡 提示: 使用 --current-milestone 可以批量迁移里程碑 + """ + console.print(f"🎯 [bold blue]批量设置里程碑: {milestone}[/bold blue]") + + manager = IssuesManager() + + result = manager.batch_set_milestone( + milestone=milestone, + state=state, + labels=label if label else None, + assignee=assignee, + milestone_filter=current_milestone, + author=author, + dry_run=dry_run, + auto_confirm=yes, + ) + + if result["skipped"] == result["total"] and not dry_run: + raise typer.Exit(1) + + +@app.command("analytics") +def analytics(): + """显示Issues统计与分析 + + 生成详细的Issues统计报告,包括: + - Issue状态分布(开放/关闭) + - 标签使用统计 + - 负责人分配情况 + - 里程碑进度 + - 活跃度趋势 + + 示例: + github-manager analytics # 生成完整统计报告 + """ console.print("📊 [bold blue]Issues统计分析[/bold blue]") manager = IssuesManager() @@ -134,6 +551,14 @@ def statistics(): raise typer.Exit(1) +# 保留 stats 作为 analytics 的别名,用于向后兼容 +@app.command("stats", hidden=True) +def statistics(): + """显示Issues统计信息(已弃用,请使用 analytics)""" + console.print("⚠️ [yellow]'stats' 命令已弃用,请使用 'analytics'[/yellow]") + analytics() + + @app.command("team") def team( update: bool = typer.Option( @@ -230,6 +655,178 @@ def show_config(): console.print(f" • 详细输出: {getattr(config, 'verbose_output', False)}") +@app.command("summarize") +def summarize_issue( + issue: int = typer.Argument(..., help="Issue 编号"), + provider: str = typer.Option("openai", "--provider", "-p", help="AI 提供商: openai, claude"), + max_length: int = typer.Option(200, "--max-length", help="最大摘要长度"), +): + """生成 Issue 的 AI 摘要 + + 使用 AI 生成简洁的 Issue 摘要,帮助快速理解问题核心。 + + 示例: + github-manager summarize 123 # 使用 OpenAI 生成摘要 + github-manager summarize 456 -p claude # 使用 Claude 生成摘要 + github-manager summarize 789 --max-length 300 # 自定义摘要长度 + + 需要设置环境变量: + export OPENAI_API_KEY=sk-... # 使用 OpenAI + export ANTHROPIC_API_KEY=sk-ant-... # 使用 Claude + """ + console.print(f"🤖 [bold blue]生成 Issue #{issue} 的 AI 摘要[/bold blue]\n") + + manager = IssuesManager() + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("生成摘要中...", total=None) + result = manager.summarize_issue(issue, api_provider=provider, max_length=max_length) + progress.update(task, completed=True) + + if not result: + console.print("❌ [red]生成摘要失败[/red]") + raise typer.Exit(1) + + # 显示结果 + console.print(f"📝 [bold cyan]Issue #{result['number']}[/bold cyan]") + console.print(f"🔗 {result['url']}\n") + console.print(f"[bold]标题:[/bold] {result['title']}\n") + console.print("[bold yellow]AI 摘要:[/bold yellow]") + console.print(f"[green]{result['summary']}[/green]") + + +@app.command("detect-duplicates") +def detect_duplicates( + threshold: float = typer.Option(0.7, "--threshold", "-t", help="相似度阈值 (0-1)"), + limit: int = typer.Option(20, "--limit", "-n", help="显示结果数量"), +): + """检测重复的 Issues + + 基于标题和内容的文本相似度检测可能重复的 Issues。 + + 示例: + github-manager detect-duplicates # 使用默认阈值 0.7 + github-manager detect-duplicates -t 0.8 # 使用更严格的阈值 + github-manager detect-duplicates -t 0.6 -n 50 # 显示更多结果 + + 提示: + - 阈值越高,匹配越严格(更少误报) + - 阈值越低,匹配越宽松(可能更多误报) + - 推荐范围: 0.6 - 0.8 + """ + console.print(f"🔍 [bold blue]检测重复 Issues (阈值: {threshold})[/bold blue]\n") + + manager = IssuesManager() + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("分析中...", total=None) + duplicates = manager.detect_duplicates(threshold=threshold) + progress.update(task, completed=True) + + if not duplicates: + console.print("✅ [green]未发现重复的 Issues[/green]") + return + + # 显示结果 + console.print(f"📊 [yellow]发现 {len(duplicates)} 对可能重复的 Issues[/yellow]\n") + + table = Table(title="重复 Issues") + table.add_column("Issue 1", style="cyan") + table.add_column("Issue 2", style="cyan") + table.add_column("相似度", style="yellow", justify="right") + + for dup in duplicates[:limit]: + issue1 = dup["issue1"] + issue2 = dup["issue2"] + similarity = dup["similarity"] + + title1 = issue1["title"][:40] + "..." if len(issue1["title"]) > 40 else issue1["title"] + title2 = issue2["title"][:40] + "..." if len(issue2["title"]) > 40 else issue2["title"] + + table.add_row( + f"#{issue1['number']}: {title1}", + f"#{issue2['number']}: {title2}", + f"{similarity:.1%}", + ) + + console.print(table) + + if len(duplicates) > limit: + console.print(f"\n💡 还有 {len(duplicates) - limit} 对结果,使用 --limit 查看更多") + + +@app.command("suggest-labels") +def suggest_labels( + issue: int = typer.Argument(..., help="Issue 编号"), +): + """为 Issue 推荐标签 + + 基于 Issue 标题和内容,推荐合适的标签。 + + 示例: + github-manager suggest-labels 123 # 为 Issue #123 推荐标签 + github-manager suggest-labels 456 # 为 Issue #456 推荐标签 + + 推荐的标签类型: + - bug: 错误、异常、崩溃 + - enhancement: 功能增强、改进 + - documentation: 文档相关 + - performance: 性能优化 + - security: 安全问题 + - test: 测试相关 + - refactor: 代码重构 + """ + console.print(f"🏷️ [bold blue]为 Issue #{issue} 推荐标签[/bold blue]\n") + + manager = IssuesManager() + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("分析中...", total=None) + result = manager.suggest_labels_for_issue(issue) + progress.update(task, completed=True) + + if not result: + console.print("❌ [red]分析失败[/red]") + raise typer.Exit(1) + + # 显示结果 + console.print(f"📝 [bold cyan]Issue #{result['number']}[/bold cyan]") + console.print(f"🔗 {result['url']}\n") + console.print(f"[bold]标题:[/bold] {result['title']}\n") + + if result["existing_labels"]: + console.print(f"[bold]现有标签:[/bold] {', '.join(result['existing_labels'])}") + else: + console.print("[bold]现有标签:[/bold] [dim]无[/dim]") + + if result["suggested_labels"]: + console.print( + f"\n[bold yellow]推荐标签:[/bold yellow] {', '.join(result['suggested_labels'])}" + ) + else: + console.print("\n[bold yellow]推荐标签:[/bold yellow] [dim]无推荐[/dim]") + + if result["new_labels"]: + console.print(f"\n[bold green]新增建议:[/bold green] {', '.join(result['new_labels'])}") + console.print("\n💡 可以使用以下命令添加标签:") + labels_str = " ".join([f"--add {label}" for label in result["new_labels"]]) + console.print(f" github-manager batch-label {labels_str} --label #{issue}") + else: + console.print("\n✅ [green]当前标签已完善,无需添加[/green]") + + @app.command("ai") def ai_analysis( action: str = typer.Option("analyze", help="AI操作类型: analyze, dedupe, optimize, report"), diff --git a/src/sage_github/cli_main.py b/src/sage_github/cli_main.py index 4e10509..e840a7b 100644 --- a/src/sage_github/cli_main.py +++ b/src/sage_github/cli_main.py @@ -10,7 +10,7 @@ def main(): """Main CLI entry point""" try: from sage_github.cli import app - + # Run the Typer app app() except KeyboardInterrupt: diff --git a/src/sage_github/config.py b/src/sage_github/config.py index 0f4f97a..729dccc 100644 --- a/src/sage_github/config.py +++ b/src/sage_github/config.py @@ -2,38 +2,58 @@ """ GitHub Issues管理工具 - 配置管理 统一的配置管理和GitHub API客户端 + +配置优先级(从低到高): +1. config.json (默认配置文件) +2. .env 文件 +3. 环境变量 +4. 函数参数(最高优先级) """ import json import os from pathlib import Path +from dotenv import load_dotenv import requests class IssuesConfig: """Issues管理配置类""" - # GitHub仓库配置 - 可以通过环境变量覆盖 - GITHUB_OWNER = os.getenv("GITHUB_OWNER", "intellistream") - GITHUB_REPO = os.getenv("GITHUB_REPO", "SAGE") - - # 专业领域匹配规则 - 可自定义 - EXPERTISE_RULES = {} - - def __init__(self, project_root: Path | None = None, github_owner: str | None = None, github_repo: str | None = None): - # 如果提供了owner和repo参数,使用它们 - if github_owner: - self.GITHUB_OWNER = github_owner - if github_repo: - self.GITHUB_REPO = github_repo - + def __init__( + self, + project_root: Path | None = None, + github_owner: str | None = None, + github_repo: str | None = None, + ): # 如果没有提供project_root,尝试找到项目根目录 if project_root is None: self.project_root = self._find_project_root() else: self.project_root = Path(project_root) + # 1. 加载 config.json(最低优先级) + config_data = self._load_config_json() + + # 2. 加载 .env 文件(中等优先级) + self._load_env_file(self.project_root) + + # 3. GitHub仓库配置 - 按优先级:参数 > 环境变量 > config.json + self.GITHUB_OWNER = ( + github_owner + or os.getenv("GITHUB_OWNER") + or config_data.get("github", {}).get("owner", "intellistream") + ) + self.GITHUB_REPO = ( + github_repo + or os.getenv("GITHUB_REPO") + or config_data.get("github", {}).get("repo", "SAGE") + ) + + # 专业领域匹配规则 - 从配置文件加载 + self.EXPERTISE_RULES: dict[str, str] = config_data.get("expertise_rules", {}) + # 工作目录配置 - 使用.github-manager目录 self.base_dir = self.project_root / ".github-manager" self.workspace_path = self.base_dir / "workspace" @@ -74,6 +94,91 @@ def _find_project_root(self) -> Path: # 最后回退到当前工作目录 return Path.cwd() + def _load_env_file(self, project_root: Path | None = None): + """ + 加载 .env 文件 + + 按优先级从以下位置查找 .env 文件: + 1. 项目根目录/.env + 2. 当前工作目录/.env + 3. 用户主目录/.github-manager/.env + """ + env_paths = [] + + # 1. 项目根目录(如果提供) + if project_root: + env_paths.append(Path(project_root) / ".env") + + # 2. 当前工作目录 + env_paths.append(Path.cwd() / ".env") + + # 3. 用户主目录的 .github-manager 目录 + home_config = Path.home() / ".github-manager" / ".env" + env_paths.append(home_config) + + # 尝试加载第一个找到的 .env 文件 + for env_path in env_paths: + if env_path.exists(): + load_dotenv(env_path, override=False) # override=False 不覆盖已存在的环境变量 + # print(f"✅ 已加载配置文件: {env_path}") + return + + # 如果没有找到 .env 文件,尝试加载默认位置 + load_dotenv(override=False) # 尝试从当前目录加载 + + def _load_config_json(self) -> dict: + """ + 加载 config.json 配置文件 + + 按优先级从以下位置查找 config.json: + 1. 项目根目录/config.json + 2. 用户主目录/.github-manager/config.json + 3. 包内默认配置 + + Returns: + 配置字典 + """ + config_paths = [ + self.project_root / "config.json", + Path.home() / ".github-manager" / "config.json", + ] + + for config_path in config_paths: + if config_path.exists(): + try: + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + # print(f"✅ 已加载配置: {config_path}") + return config + except Exception as e: + print(f"⚠️ 加载配置文件失败 {config_path}: {e}") + + # 返回默认配置 + return { + "github": { + "owner": "intellistream", + "repo": "SAGE", + "token_env_names": [ + "GITHUB_TOKEN", + "GH_TOKEN", + "GIT_TOKEN", + "SAGE_REPO_TOKEN", + ], + }, + "paths": { + "base_dir": ".github-manager", + "workspace": "workspace", + "output": "output", + "metadata": "metadata", + }, + "settings": { + "sync_update_history": True, + "auto_backup": True, + "verbose_output": False, + }, + "expertise_rules": {}, + } + def _load_user_settings(self): """加载用户设置""" settings_file = self.metadata_path / "settings.json" diff --git a/src/sage_github/helpers/_github_operations.py b/src/sage_github/helpers/_github_operations.py index 790b8f9..17f5c02 100644 --- a/src/sage_github/helpers/_github_operations.py +++ b/src/sage_github/helpers/_github_operations.py @@ -4,9 +4,9 @@ 基于AI分析结果执行GitHub操作 """ +from pathlib import Path import sys import time -from pathlib import Path import requests diff --git a/src/sage_github/helpers/ai_analyzer.py b/src/sage_github/helpers/ai_analyzer.py index 6877890..6865807 100644 --- a/src/sage_github/helpers/ai_analyzer.py +++ b/src/sage_github/helpers/ai_analyzer.py @@ -6,10 +6,10 @@ """ import argparse -import re -import sys from datetime import datetime from pathlib import Path +import re +import sys SCRIPT_DIR = Path(__file__).resolve().parent diff --git a/src/sage_github/helpers/ai_helper.py b/src/sage_github/helpers/ai_helper.py new file mode 100644 index 0000000..ea55ffc --- /dev/null +++ b/src/sage_github/helpers/ai_helper.py @@ -0,0 +1,307 @@ +"""AI 功能助手 + +提供 Issue 摘要、重复检测、标签建议等 AI 功能。 +支持 OpenAI 和 Anthropic Claude API。 +""" + +import os +from typing import Any + +from rich.console import Console + +console = Console() + + +class AIHelper: + """AI 助手类 + + 提供基于 AI 的 Issue 分析功能。 + """ + + def __init__( + self, api_provider: str = "openai", api_key: str | None = None, silent: bool = False + ): + """初始化 AI 助手 + + Args: + api_provider: API 提供商 (openai/claude) + api_key: API 密钥 + silent: 静默模式(不打印警告) + """ + self.api_provider = api_provider.lower() + self.api_key = api_key or self._get_api_key() + self.silent = silent + + if not self.api_key and not silent: + console.print("⚠️ [yellow]未配置 API Key[/yellow]") + console.print("💡 设置环境变量:") + if self.api_provider == "openai": + console.print(" export OPENAI_API_KEY=sk-...") + else: + console.print(" export ANTHROPIC_API_KEY=sk-ant-...") + + def _get_api_key(self) -> str | None: + """获取 API 密钥""" + if self.api_provider == "openai": + return os.getenv("OPENAI_API_KEY") + elif self.api_provider == "claude": + return os.getenv("ANTHROPIC_API_KEY") + return None + + def is_available(self) -> bool: + """检查 AI 功能是否可用""" + if not self.api_key: + return False + + try: + if self.api_provider == "openai": + import openai # noqa: F401 + + return True + elif self.api_provider == "claude": + import anthropic # noqa: F401 + + return True + except ImportError: + console.print(f"⚠️ [yellow]{self.api_provider} 库未安装[/yellow]") + if self.api_provider == "openai": + console.print("💡 安装: pip install openai") + else: + console.print("💡 安装: pip install anthropic") + return False + + return False + + def summarize_issue(self, issue: dict[str, Any], max_length: int = 200) -> str | None: + """生成 Issue 摘要 + + Args: + issue: Issue 数据 + max_length: 最大摘要长度 + + Returns: + 摘要文本,如果失败返回 None + """ + if not self.is_available(): + return None + + title = issue.get("title", "") + body = issue.get("body", "") + + if not body: + return title + + prompt = f"""请用中文简洁总结以下 GitHub Issue(不超过{max_length}字): + +标题: {title} + +内容: +{body[:2000]} # 限制输入长度 + +要求: +1. 一句话概括问题核心 +2. 如果有解决方案或建议,简要提及 +3. 语言简洁、专业 +""" + + try: + if self.api_provider == "openai": + return self._summarize_with_openai(prompt, max_length) + elif self.api_provider == "claude": + return self._summarize_with_claude(prompt, max_length) + except Exception as e: + console.print(f"❌ [red]生成摘要失败: {e}[/red]") + return None + + return None + + def _summarize_with_openai(self, prompt: str, max_length: int) -> str | None: + """使用 OpenAI 生成摘要""" + try: + import openai + + client = openai.OpenAI(api_key=self.api_key) + + response = client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[ + { + "role": "system", + "content": "你是一个专业的技术文档助手,擅长总结 GitHub Issues。", + }, + {"role": "user", "content": prompt}, + ], + max_tokens=max_length, + temperature=0.3, + ) + + return response.choices[0].message.content.strip() + except Exception as e: + console.print(f"❌ [red]OpenAI API 调用失败: {e}[/red]") + return None + + def _summarize_with_claude(self, prompt: str, max_length: int) -> str | None: + """使用 Claude 生成摘要""" + try: + import anthropic + + client = anthropic.Anthropic(api_key=self.api_key) + + message = client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=max_length, + messages=[{"role": "user", "content": prompt}], + ) + + return message.content[0].text.strip() + except Exception as e: + console.print(f"❌ [red]Claude API 调用失败: {e}[/red]") + return None + + def detect_duplicates( + self, issues: list[dict[str, Any]], threshold: float = 0.7 + ) -> list[tuple[dict[str, Any], dict[str, Any], float]]: + """检测重复的 Issues + + 使用标题和内容的文本相似度检测重复。 + + Args: + issues: Issues 列表 + threshold: 相似度阈值 (0-1) + + Returns: + 重复对列表,每个元素为 (issue1, issue2, similarity) + """ + from difflib import SequenceMatcher + + duplicates = [] + + for i in range(len(issues)): + for j in range(i + 1, len(issues)): + issue1 = issues[i] + issue2 = issues[j] + + # 计算标题相似度 + title1 = issue1.get("title", "").lower() + title2 = issue2.get("title", "").lower() + + if not title1 or not title2: + continue + + title_sim = SequenceMatcher(None, title1, title2).ratio() + + # 如果标题非常相似,检查内容 + if title_sim > threshold: + body1 = (issue1.get("body") or "")[:500].lower() + body2 = (issue2.get("body") or "")[:500].lower() + + if body1 and body2: + body_sim = SequenceMatcher(None, body1, body2).ratio() + similarity = title_sim * 0.7 + body_sim * 0.3 # 标题权重更高 + else: + similarity = title_sim + + if similarity > threshold: + duplicates.append((issue1, issue2, similarity)) + + # 按相似度排序 + duplicates.sort(key=lambda x: x[2], reverse=True) + return duplicates + + def suggest_labels(self, issue: dict[str, Any]) -> list[str]: + """为 Issue 推荐标签 + + 基于标题和内容关键词推荐合适的标签。 + + Args: + issue: Issue 数据 + + Returns: + 推荐的标签列表 + """ + title = issue.get("title", "").lower() + body = (issue.get("body") or "").lower() + text = f"{title} {body}" + + # 关键词映射到标签 + label_keywords = { + "bug": [ + "bug", + "error", + "错误", + "异常", + "exception", + "fail", + "失败", + "crash", + "崩溃", + ], + "enhancement": [ + "feature", + "enhance", + "improve", + "增强", + "改进", + "优化", + "新功能", + "add", + ], + "documentation": ["doc", "文档", "readme", "guide", "tutorial", "教程"], + "performance": ["performance", "性能", "slow", "慢", "latency", "延迟", "optimize"], + "security": ["security", "安全", "vulnerability", "漏洞", "cve"], + "test": ["test", "测试", "unit test", "integration"], + "refactor": ["refactor", "重构", "cleanup", "清理"], + "dependency": ["dependency", "依赖", "package", "upgrade", "update"], + "breaking-change": ["breaking", "破坏性", "incompatible", "不兼容"], + "good-first-issue": ["easy", "简单", "beginner", "新手"], + } + + suggested = [] + for label, keywords in label_keywords.items(): + for keyword in keywords: + if keyword in text: + suggested.append(label) + break # 找到一个关键词就够了 + + return list(set(suggested)) # 去重 + + def analyze_issues_batch( + self, issues: list[dict[str, Any]], operation: str = "summarize" + ) -> dict[str, Any]: + """批量分析 Issues + + Args: + issues: Issues 列表 + operation: 操作类型 (summarize/labels/all) + + Returns: + 分析结果字典 + """ + results = {"total": len(issues), "processed": 0, "failed": 0, "data": []} + + for issue in issues: + try: + item = { + "number": issue.get("number"), + "title": issue.get("title"), + "url": issue.get("html_url"), + } + + if operation in ["summarize", "all"]: + summary = self.summarize_issue(issue) + if summary: + item["summary"] = summary + + if operation in ["labels", "all"]: + labels = self.suggest_labels(issue) + if labels: + item["suggested_labels"] = labels + + results["data"].append(item) + results["processed"] += 1 + + except Exception as e: + console.print(f"❌ 处理 Issue #{issue.get('number')} 失败: {e}") + results["failed"] += 1 + + return results diff --git a/src/sage_github/helpers/batch_operations.py b/src/sage_github/helpers/batch_operations.py new file mode 100644 index 0000000..f63280c --- /dev/null +++ b/src/sage_github/helpers/batch_operations.py @@ -0,0 +1,489 @@ +"""批量操作功能 + +支持批量关闭、标签管理、分配和里程碑设置。 +""" + +from typing import Any + +import requests +from rich.console import Console +from rich.prompt import Confirm +from rich.table import Table + +console = Console() + + +class BatchOperations: + """批量操作管理器 + + 提供批量关闭、标签管理、分配和里程碑设置功能。 + 所有操作支持 dry-run 模式和确认提示。 + """ + + def __init__(self, owner: str, repo: str, token: str): + """初始化批量操作管理器 + + Args: + owner: GitHub仓库所有者 + repo: GitHub仓库名称 + token: GitHub访问令牌 + """ + self.owner = owner + self.repo = repo + self.token = token + self.headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json", + } + self.base_url = f"https://api.github.com/repos/{owner}/{repo}" + + def _update_issue(self, issue_number: int, **kwargs) -> bool: + """更新Issue + + Args: + issue_number: Issue编号 + **kwargs: 要更新的字段 + + Returns: + 是否成功 + """ + url = f"{self.base_url}/issues/{issue_number}" + response = requests.patch(url, headers=self.headers, json=kwargs, timeout=30) + return response.status_code == 200 + + def _get_milestones(self) -> list[dict[str, Any]]: + """获取里程碑列表 + + Returns: + 里程碑列表 + """ + url = f"{self.base_url}/milestones" + response = requests.get(url, headers=self.headers, params={"state": "all"}, timeout=30) + if response.status_code == 200: + return response.json() + return [] + + def close_issues( + self, + issues: list[dict[str, Any]], + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量关闭 Issues + + Args: + issues: 要关闭的 Issues 列表 + dry_run: 是否为预览模式(不实际执行) + auto_confirm: 是否自动确认(跳过用户确认) + + Returns: + 操作结果统计信息 + """ + if not issues: + console.print("⚠️ [yellow]没有匹配的 Issues[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + # 显示预览表格 + self._show_preview_table(issues, "Close") + + # Dry-run 模式直接返回 + if dry_run: + console.print(f"\n🔍 [yellow]预览模式: 将关闭 {len(issues)} 个 Issues[/yellow]") + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + # 确认操作 + if not auto_confirm: + if not Confirm.ask(f"\n❓ 确认关闭 {len(issues)} 个 Issues?"): + console.print("❌ [red]操作已取消[/red]") + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + # 执行关闭操作 + success_count = 0 + failed_count = 0 + + from rich.progress import Progress, SpinnerColumn, TextColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("关闭 Issues...", total=len(issues)) + + for issue in issues: + try: + self._update_issue(issue["number"], state="closed") + success_count += 1 + except Exception as e: + console.print(f"❌ [red]关闭 #{issue['number']} 失败: {e}[/red]") + failed_count += 1 + progress.advance(task) + + # 显示结果 + console.print(f"\n✅ [green]成功关闭 {success_count} 个 Issues[/green]") + if failed_count > 0: + console.print(f"❌ [red]失败 {failed_count} 个[/red]") + + return { + "total": len(issues), + "success": success_count, + "failed": failed_count, + "skipped": 0, + } + + def add_labels( + self, + issues: list[dict[str, Any]], + labels: list[str], + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量添加标签 + + Args: + issues: 要添加标签的 Issues 列表 + labels: 要添加的标签列表 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计信息 + """ + if not issues: + console.print("⚠️ [yellow]没有匹配的 Issues[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + if not labels: + console.print("⚠️ [yellow]请指定要添加的标签[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + # 显示预览 + self._show_preview_table(issues, f"Add labels: {', '.join(labels)}") + + if dry_run: + console.print( + f"\n🔍 [yellow]预览模式: 将为 {len(issues)} 个 Issues 添加标签 {labels}[/yellow]" + ) + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + if not auto_confirm: + if not Confirm.ask(f"\n❓ 确认为 {len(issues)} 个 Issues 添加标签 {labels}?"): + console.print("❌ [red]操作已取消[/red]") + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + # 执行添加标签 + success_count = 0 + failed_count = 0 + + from rich.progress import Progress, SpinnerColumn, TextColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("添加标签...", total=len(issues)) + + for issue in issues: + try: + # 获取现有标签 + existing_labels = [label["name"] for label in issue.get("labels", [])] + # 合并标签(去重) + new_labels = list(set(existing_labels + labels)) + self._update_issue(issue["number"], labels=new_labels) + success_count += 1 + except Exception as e: + console.print(f"❌ [red]为 #{issue['number']} 添加标签失败: {e}[/red]") + failed_count += 1 + progress.advance(task) + + console.print(f"\n✅ [green]成功为 {success_count} 个 Issues 添加标签[/green]") + if failed_count > 0: + console.print(f"❌ [red]失败 {failed_count} 个[/red]") + + return { + "total": len(issues), + "success": success_count, + "failed": failed_count, + "skipped": 0, + } + + def remove_labels( + self, + issues: list[dict[str, Any]], + labels: list[str], + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量移除标签 + + Args: + issues: 要移除标签的 Issues 列表 + labels: 要移除的标签列表 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计信息 + """ + if not issues: + console.print("⚠️ [yellow]没有匹配的 Issues[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + if not labels: + console.print("⚠️ [yellow]请指定要移除的标签[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + # 显示预览 + self._show_preview_table(issues, f"Remove labels: {', '.join(labels)}") + + if dry_run: + console.print( + f"\n🔍 [yellow]预览模式: 将从 {len(issues)} 个 Issues 移除标签 {labels}[/yellow]" + ) + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + if not auto_confirm: + if not Confirm.ask(f"\n❓ 确认从 {len(issues)} 个 Issues 移除标签 {labels}?"): + console.print("❌ [red]操作已取消[/red]") + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + # 执行移除标签 + success_count = 0 + failed_count = 0 + + from rich.progress import Progress, SpinnerColumn, TextColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("移除标签...", total=len(issues)) + + for issue in issues: + try: + # 获取现有标签并移除指定标签 + existing_labels = [label["name"] for label in issue.get("labels", [])] + new_labels = [label for label in existing_labels if label not in labels] + self._update_issue(issue["number"], labels=new_labels) + success_count += 1 + except Exception as e: + console.print(f"❌ [red]从 #{issue['number']} 移除标签失败: {e}[/red]") + failed_count += 1 + progress.advance(task) + + console.print(f"\n✅ [green]成功从 {success_count} 个 Issues 移除标签[/green]") + if failed_count > 0: + console.print(f"❌ [red]失败 {failed_count} 个[/red]") + + return { + "total": len(issues), + "success": success_count, + "failed": failed_count, + "skipped": 0, + } + + def assign_issues( + self, + issues: list[dict[str, Any]], + assignees: list[str], + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量分配 Issues + + Args: + issues: 要分配的 Issues 列表 + assignees: 负责人列表 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计信息 + """ + if not issues: + console.print("⚠️ [yellow]没有匹配的 Issues[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + if not assignees: + console.print("⚠️ [yellow]请指定负责人[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + # 显示预览 + self._show_preview_table(issues, f"Assign to: {', '.join(assignees)}") + + if dry_run: + console.print( + f"\n🔍 [yellow]预览模式: 将分配 {len(issues)} 个 Issues 给 {assignees}[/yellow]" + ) + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + if not auto_confirm: + if not Confirm.ask(f"\n❓ 确认分配 {len(issues)} 个 Issues 给 {assignees}?"): + console.print("❌ [red]操作已取消[/red]") + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + # 执行分配 + success_count = 0 + failed_count = 0 + + from rich.progress import Progress, SpinnerColumn, TextColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("分配 Issues...", total=len(issues)) + + for issue in issues: + try: + self._update_issue(issue["number"], assignees=assignees) + success_count += 1 + except Exception as e: + console.print(f"❌ [red]分配 #{issue['number']} 失败: {e}[/red]") + failed_count += 1 + progress.advance(task) + + console.print(f"\n✅ [green]成功分配 {success_count} 个 Issues[/green]") + if failed_count > 0: + console.print(f"❌ [red]失败 {failed_count} 个[/red]") + + return { + "total": len(issues), + "success": success_count, + "failed": failed_count, + "skipped": 0, + } + + def set_milestone( + self, + issues: list[dict[str, Any]], + milestone: str, + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量设置里程碑 + + Args: + issues: 要设置里程碑的 Issues 列表 + milestone: 里程碑名称 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计信息 + """ + if not issues: + console.print("⚠️ [yellow]没有匹配的 Issues[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + if not milestone: + console.print("⚠️ [yellow]请指定里程碑[/yellow]") + return {"total": 0, "success": 0, "failed": 0, "skipped": 0} + + # 显示预览 + self._show_preview_table(issues, f"Set milestone: {milestone}") + + if dry_run: + console.print( + f"\n🔍 [yellow]预览模式: 将为 {len(issues)} 个 Issues 设置里程碑 '{milestone}'[/yellow]" + ) + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + if not auto_confirm: + if not Confirm.ask(f"\n❓ 确认为 {len(issues)} 个 Issues 设置里程碑 '{milestone}'?"): + console.print("❌ [red]操作已取消[/red]") + return {"total": len(issues), "success": 0, "failed": 0, "skipped": len(issues)} + + # 先获取里程碑 ID + try: + milestone_id = self._get_milestone_id(milestone) + if milestone_id is None: + console.print(f"❌ [red]找不到里程碑 '{milestone}'[/red]") + return {"total": len(issues), "success": 0, "failed": len(issues), "skipped": 0} + except Exception as e: + console.print(f"❌ [red]获取里程碑失败: {e}[/red]") + return {"total": len(issues), "success": 0, "failed": len(issues), "skipped": 0} + + # 执行设置里程碑 + success_count = 0 + failed_count = 0 + + from rich.progress import Progress, SpinnerColumn, TextColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("设置里程碑...", total=len(issues)) + + for issue in issues: + try: + self._update_issue(issue["number"], milestone=milestone_id) + success_count += 1 + except Exception as e: + console.print(f"❌ [red]为 #{issue['number']} 设置里程碑失败: {e}[/red]") + failed_count += 1 + progress.advance(task) + + console.print(f"\n✅ [green]成功为 {success_count} 个 Issues 设置里程碑[/green]") + if failed_count > 0: + console.print(f"❌ [red]失败 {failed_count} 个[/red]") + + return { + "total": len(issues), + "success": success_count, + "failed": failed_count, + "skipped": 0, + } + + def _get_milestone_id(self, milestone_title: str) -> int | None: + """获取里程碑 ID + + Args: + milestone_title: 里程碑标题 + + Returns: + 里程碑 ID,如果不存在返回 None + """ + milestones = self._get_milestones() + for milestone in milestones: + if milestone["title"] == milestone_title: + return milestone["number"] + return None + + def _show_preview_table(self, issues: list[dict[str, Any]], operation: str) -> None: + """显示预览表格 + + Args: + issues: Issues 列表 + operation: 操作描述 + """ + console.print(f"\n🔍 [yellow]将要执行的操作: {operation}[/yellow]\n") + + # 限制显示前 20 个 + display_issues = issues[:20] + + table = Table(title=f"匹配的 Issues (共 {len(issues)} 个)") + table.add_column("#", style="cyan", no_wrap=True) + table.add_column("标题", style="white") + table.add_column("状态", style="magenta") + table.add_column("标签", style="green") + + for issue in display_issues: + labels = ", ".join([label["name"] for label in issue.get("labels", [])]) + state_emoji = "🟢" if issue["state"] == "open" else "🔴" + table.add_row( + str(issue["number"]), + issue["title"][:50] + "..." if len(issue["title"]) > 50 else issue["title"], + f"{state_emoji} {issue['state']}", + labels[:30] + "..." if len(labels) > 30 else labels, + ) + + if len(issues) > 20: + table.caption = f"... 还有 {len(issues) - 20} 个 Issues" + + console.print(table) diff --git a/src/sage_github/helpers/create_issue.py b/src/sage_github/helpers/create_issue.py index 7f49009..b377129 100644 --- a/src/sage_github/helpers/create_issue.py +++ b/src/sage_github/helpers/create_issue.py @@ -6,8 +6,8 @@ import argparse import json -import sys from pathlib import Path +import sys import requests diff --git a/src/sage_github/helpers/download_issues.py b/src/sage_github/helpers/download_issues.py index ea32f53..79ce94d 100644 --- a/src/sage_github/helpers/download_issues.py +++ b/src/sage_github/helpers/download_issues.py @@ -5,9 +5,9 @@ """ import argparse +from datetime import datetime import json import sys -from datetime import datetime import requests @@ -539,9 +539,9 @@ def generate_download_report( closed_count = len([i for i in issues if i["state"] == "closed"]) # 标签统计 - label_stats = {} - milestone_stats = {} - team_stats = {} + label_stats: dict[str, int] = {} + milestone_stats: dict[str, int] = {} + team_stats: dict[str, int] = {} for issue in issues: # 标签统计 diff --git a/src/sage_github/helpers/execute_fix_plan.py b/src/sage_github/helpers/execute_fix_plan.py index c642a6b..02fae00 100644 --- a/src/sage_github/helpers/execute_fix_plan.py +++ b/src/sage_github/helpers/execute_fix_plan.py @@ -24,9 +24,9 @@ """ import json +from pathlib import Path import sys import time -from pathlib import Path from github_helper import GitHubProjectManager diff --git a/src/sage_github/helpers/export_issues.py b/src/sage_github/helpers/export_issues.py new file mode 100644 index 0000000..c0dc040 --- /dev/null +++ b/src/sage_github/helpers/export_issues.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +""" +Issues导出工具 +支持导出为CSV、Markdown、JSON格式 +""" + +import csv +from datetime import datetime +import json +from pathlib import Path +from typing import Any + + +class IssuesExporter: + """Issues导出器""" + + def __init__(self, issues: list[dict[str, Any]]): + """ + 初始化导出器 + + Args: + issues: Issues列表 + """ + self.issues = issues + + def export_to_csv(self, output_path: Path) -> bool: + """ + 导出为CSV格式 + + Args: + output_path: 输出文件路径 + + Returns: + 是否成功 + """ + if not self.issues: + print("⚠️ 没有Issues需要导出") + return False + + try: + with open(output_path, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + + # 写入表头 + writer.writerow( + [ + "Number", + "Title", + "State", + "Author", + "Labels", + "Assignees", + "Milestone", + "Created", + "Updated", + "Closed", + "Comments", + "URL", + ] + ) + + # 写入数据 + for issue in self.issues: + # 提取数据 + number = issue.get("number", "") + title = issue.get("title", "") + state = issue.get("state", "") + + # 作者 + user = issue.get("user", {}) + author = user.get("login", "") if isinstance(user, dict) else str(user) + + # 标签 + labels = issue.get("labels", []) + label_names = [ + label["name"] if isinstance(label, dict) else label for label in labels + ] + labels_str = ", ".join(label_names) + + # 负责人 + assignees = issue.get("assignees", []) + assignee_names = [a["login"] if isinstance(a, dict) else a for a in assignees] + assignees_str = ", ".join(assignee_names) + + # 里程碑 + milestone = issue.get("milestone", {}) + milestone_title = ( + milestone.get("title", "") + if isinstance(milestone, dict) + else str(milestone) + if milestone + else "" + ) + + # 时间 + created_at = issue.get("created_at", "") + updated_at = issue.get("updated_at", "") + closed_at = issue.get("closed_at", "") + + # 评论数 + comments = issue.get("comments", 0) + + # URL + url = issue.get("html_url", "") + + writer.writerow( + [ + number, + title, + state, + author, + labels_str, + assignees_str, + milestone_title, + created_at, + updated_at, + closed_at, + comments, + url, + ] + ) + + return True + except Exception as e: + print(f"❌ CSV导出失败: {e}") + return False + + def export_to_json(self, output_path: Path, pretty: bool = True) -> bool: + """ + 导出为JSON格式 + + Args: + output_path: 输出文件路径 + pretty: 是否格式化输出 + + Returns: + 是否成功 + """ + if not self.issues: + print("⚠️ 没有Issues需要导出") + return False + + try: + with open(output_path, "w", encoding="utf-8") as f: + if pretty: + json.dump(self.issues, f, indent=2, ensure_ascii=False) + else: + json.dump(self.issues, f, ensure_ascii=False) + + return True + except Exception as e: + print(f"❌ JSON导出失败: {e}") + return False + + def export_to_markdown(self, output_path: Path, template: str = "default") -> bool: + """ + 导出为Markdown格式 + + Args: + output_path: 输出文件路径 + template: 模板类型 (default/roadmap/report) + + Returns: + 是否成功 + """ + if not self.issues: + print("⚠️ 没有Issues需要导出") + return False + + try: + if template == "roadmap": + content = self._generate_roadmap_markdown() + elif template == "report": + content = self._generate_report_markdown() + else: + content = self._generate_default_markdown() + + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) + + return True + except Exception as e: + print(f"❌ Markdown导出失败: {e}") + return False + + def _generate_default_markdown(self) -> str: + """生成默认Markdown格式""" + lines = [] + lines.append("# Issues Export Report") + lines.append(f"\n**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"\n**Total Issues**: {len(self.issues)}") + lines.append("\n---\n") + + for issue in self.issues: + number = issue.get("number", "N/A") + title = issue.get("title", "Untitled") + state = issue.get("state", "unknown") + url = issue.get("html_url", "") + + lines.append(f"\n## #{number} - {title}") + lines.append(f"\n**State**: {state}") + + # 作者 + user = issue.get("user", {}) + author = user.get("login", "") if isinstance(user, dict) else str(user) + if author: + lines.append(f"**Author**: @{author}") + + # 标签 + labels = issue.get("labels", []) + if labels: + label_names = [ + label["name"] if isinstance(label, dict) else label for label in labels + ] + lines.append(f"**Labels**: {', '.join(label_names)}") + + # 负责人 + assignees = issue.get("assignees", []) + if assignees: + assignee_names = [a["login"] if isinstance(a, dict) else a for a in assignees] + lines.append(f"**Assignees**: {', '.join(['@' + a for a in assignee_names])}") + + # 里程碑 + milestone = issue.get("milestone", {}) + if milestone: + milestone_title = ( + milestone.get("title", "") if isinstance(milestone, dict) else str(milestone) + ) + if milestone_title: + lines.append(f"**Milestone**: {milestone_title}") + + # 正文摘要 + body = issue.get("body", "") + if body: + summary = body[:200].replace("\n", " ") + if len(body) > 200: + summary += "..." + lines.append(f"\n{summary}") + + if url: + lines.append(f"\n[View on GitHub]({url})") + + lines.append("\n---\n") + + return "\n".join(lines) + + def _generate_roadmap_markdown(self) -> str: + """生成路线图格式的Markdown""" + lines = [] + lines.append("# Project Roadmap") + lines.append(f"\n**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"\n**Total Items**: {len(self.issues)}") + lines.append("\n---\n") + + # 按里程碑分组 + by_milestone: dict[str, list] = {} + no_milestone = [] + + for issue in self.issues: + milestone = issue.get("milestone", {}) + if milestone and isinstance(milestone, dict): + milestone_title = milestone.get("title", "No Milestone") + elif milestone: + milestone_title = str(milestone) + else: + milestone_title = None + + if milestone_title: + if milestone_title not in by_milestone: + by_milestone[milestone_title] = [] + by_milestone[milestone_title].append(issue) + else: + no_milestone.append(issue) + + # 输出各里程碑 + for milestone_title, milestone_issues in sorted(by_milestone.items()): + lines.append(f"\n## {milestone_title}") + lines.append(f"\n**Items**: {len(milestone_issues)}\n") + + for issue in milestone_issues: + number = issue.get("number", "") + title = issue.get("title", "") + state = issue.get("state", "") + state_emoji = "✅" if state == "closed" else "🔵" + + # 标签 + labels = issue.get("labels", []) + label_names = [ + label["name"] if isinstance(label, dict) else label for label in labels + ] + labels_str = f" `{', '.join(label_names)}`" if label_names else "" + + lines.append(f"- {state_emoji} #{number} - {title}{labels_str}") + + # 无里程碑的Issues + if no_milestone: + lines.append("\n## Backlog (No Milestone)") + lines.append(f"\n**Items**: {len(no_milestone)}\n") + + for issue in no_milestone: + number = issue.get("number", "") + title = issue.get("title", "") + state = issue.get("state", "") + state_emoji = "✅" if state == "closed" else "🔵" + lines.append(f"- {state_emoji} #{number} - {title}") + + return "\n".join(lines) + + def _generate_report_markdown(self) -> str: + """生成报告格式的Markdown""" + lines = [] + lines.append("# Issues Report") + lines.append(f"\n**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append(f"\n**Total Issues**: {len(self.issues)}") + + # 统计信息 + open_count = sum(1 for i in self.issues if i.get("state") == "open") + closed_count = sum(1 for i in self.issues if i.get("state") == "closed") + + lines.append("\n## Summary\n") + lines.append(f"- **Open Issues**: {open_count}") + lines.append(f"- **Closed Issues**: {closed_count}") + + # 按状态分组 + lines.append("\n---\n") + lines.append("\n## Open Issues\n") + + for issue in self.issues: + if issue.get("state") != "open": + continue + + number = issue.get("number", "") + title = issue.get("title", "") + url = issue.get("html_url", "") + + # 标签 + labels = issue.get("labels", []) + label_names = [label["name"] if isinstance(label, dict) else label for label in labels] + labels_str = f" `{', '.join(label_names)}`" if label_names else "" + + lines.append(f"- [#{number}]({url}) - {title}{labels_str}") + + return "\n".join(lines) diff --git a/src/sage_github/helpers/filter_issues.py b/src/sage_github/helpers/filter_issues.py new file mode 100644 index 0000000..ea010be --- /dev/null +++ b/src/sage_github/helpers/filter_issues.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Issues过滤工具 +提供灵活的Issues筛选功能 +""" + +from datetime import datetime +from typing import Any + + +class IssuesFilter: + """Issues过滤器""" + + def __init__(self, issues: list[dict[str, Any]]): + """ + 初始化过滤器 + + Args: + issues: Issues列表 + """ + self.issues = issues + + def filter_by_state(self, state: str) -> list[dict[str, Any]]: + """ + 按状态过滤 + + Args: + state: 状态 (open/closed/all) + + Returns: + 过滤后的Issues列表 + """ + if state == "all": + return self.issues + return [issue for issue in self.issues if issue.get("state") == state] + + def filter_by_labels(self, labels: list[str]) -> list[dict[str, Any]]: + """ + 按标签过滤 (AND逻辑 - 必须包含所有指定标签) + + Args: + labels: 标签列表 + + Returns: + 过滤后的Issues列表 + """ + if not labels: + return self.issues + + result = [] + for issue in self.issues: + issue_labels = [ + label["name"] if isinstance(label, dict) else label + for label in issue.get("labels", []) + ] + if all(label in issue_labels for label in labels): + result.append(issue) + return result + + def filter_by_assignee(self, assignee: str | None) -> list[dict[str, Any]]: + """ + 按负责人过滤 + + Args: + assignee: 负责人用户名 (None表示未分配) + + Returns: + 过滤后的Issues列表 + """ + if assignee is None: + # 未分配 + return [ + issue + for issue in self.issues + if not issue.get("assignees") or len(issue.get("assignees", [])) == 0 + ] + + result = [] + for issue in self.issues: + assignees = issue.get("assignees", []) + assignee_names = [a["login"] if isinstance(a, dict) else a for a in assignees] + if assignee in assignee_names: + result.append(issue) + return result + + def filter_by_milestone(self, milestone: str | None) -> list[dict[str, Any]]: + """ + 按里程碑过滤 + + Args: + milestone: 里程碑名称 (None表示未设置里程碑) + + Returns: + 过滤后的Issues列表 + """ + if milestone is None: + # 未设置里程碑 + return [issue for issue in self.issues if not issue.get("milestone")] + + result = [] + for issue in self.issues: + ms = issue.get("milestone") + if ms: + ms_title = ms["title"] if isinstance(ms, dict) else ms + if ms_title == milestone: + result.append(issue) + return result + + def filter_by_author(self, author: str) -> list[dict[str, Any]]: + """ + 按创建者过滤 + + Args: + author: 创建者用户名 + + Returns: + 过滤后的Issues列表 + """ + result = [] + for issue in self.issues: + user = issue.get("user", {}) + user_login = user.get("login") if isinstance(user, dict) else user + if user_login == author: + result.append(issue) + return result + + def sort_issues( + self, issues: list[dict[str, Any]], sort_by: str = "created", reverse: bool = True + ) -> list[dict[str, Any]]: + """ + 排序Issues + + Args: + issues: Issues列表 + sort_by: 排序字段 (created/updated/comments/number) + reverse: 是否降序 + + Returns: + 排序后的Issues列表 + """ + if sort_by == "created": + return sorted( + issues, + key=lambda x: datetime.fromisoformat( + x.get("created_at", "1970-01-01T00:00:00Z").replace("Z", "+00:00") + ), + reverse=reverse, + ) + elif sort_by == "updated": + return sorted( + issues, + key=lambda x: datetime.fromisoformat( + x.get("updated_at", "1970-01-01T00:00:00Z").replace("Z", "+00:00") + ), + reverse=reverse, + ) + elif sort_by == "comments": + return sorted(issues, key=lambda x: x.get("comments", 0), reverse=reverse) + elif sort_by == "number": + return sorted(issues, key=lambda x: x.get("number", 0), reverse=reverse) + else: + return issues + + def apply_filters( + self, + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + sort_by: str = "created", + reverse: bool = True, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """ + 应用多个过滤条件 + + Args: + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + sort_by: 排序字段 + reverse: 是否降序 + limit: 限制结果数量 + + Returns: + 过滤和排序后的Issues列表 + """ + result = self.issues + + # 应用状态过滤 + if state != "all": + result = [issue for issue in result if issue.get("state") == state] + + # 应用标签过滤 + if labels: + filtered = [] + for issue in result: + issue_labels = [ + label["name"] if isinstance(label, dict) else label + for label in issue.get("labels", []) + ] + if all(label in issue_labels for label in labels): + filtered.append(issue) + result = filtered + + # 应用负责人过滤 + if assignee is not None: + if assignee == "": + # 未分配 + result = [ + issue + for issue in result + if not issue.get("assignees") or len(issue.get("assignees", [])) == 0 + ] + else: + filtered = [] + for issue in result: + assignees = issue.get("assignees", []) + assignee_names = [a["login"] if isinstance(a, dict) else a for a in assignees] + if assignee in assignee_names: + filtered.append(issue) + result = filtered + + # 应用里程碑过滤 + if milestone is not None: + if milestone == "": + # 未设置里程碑 + result = [issue for issue in result if not issue.get("milestone")] + else: + filtered = [] + for issue in result: + ms = issue.get("milestone") + if ms: + ms_title = ms["title"] if isinstance(ms, dict) else ms + if ms_title == milestone: + filtered.append(issue) + result = filtered + + # 应用创建者过滤 + if author: + filtered = [] + for issue in result: + user = issue.get("user", {}) + user_login = user.get("login") if isinstance(user, dict) else user + if user_login == author: + filtered.append(issue) + result = filtered + + # 排序 + result = self.sort_issues(result, sort_by, reverse) + + # 限制数量 + if limit and limit > 0: + result = result[:limit] + + return result diff --git a/src/sage_github/helpers/get_boards.py b/src/sage_github/helpers/get_boards.py index 5a7b27d..92eb518 100755 --- a/src/sage_github/helpers/get_boards.py +++ b/src/sage_github/helpers/get_boards.py @@ -11,10 +11,10 @@ 日期: 2025-08-30 """ -import json -import sys from datetime import datetime +import json from pathlib import Path +import sys import requests diff --git a/src/sage_github/helpers/get_paths.py b/src/sage_github/helpers/get_paths.py index ff420a0..0e51c52 100644 --- a/src/sage_github/helpers/get_paths.py +++ b/src/sage_github/helpers/get_paths.py @@ -4,8 +4,8 @@ 用于shell脚本调用 """ -import sys from pathlib import Path +import sys # 动态导入config模块 try: diff --git a/src/sage_github/helpers/get_team_members.py b/src/sage_github/helpers/get_team_members.py index 4839704..94c5dd2 100644 --- a/src/sage_github/helpers/get_team_members.py +++ b/src/sage_github/helpers/get_team_members.py @@ -11,32 +11,21 @@ Token resolution order: GITHUB_TOKEN env var -> .github_token file searched upward from repo -> user's home .github_token """ -import json -import sys from datetime import datetime +import json from pathlib import Path +import sys import requests # Import IssuesConfig robustly whether run as a module or as a script try: - # Preferred: absolute import via installed/available package path - from sage.tools.dev.issues.config import IssuesConfig as Config -except Exception: - # Fallback: when executed directly, ensure the package src root is on sys.path - current = Path(__file__).resolve() - src_path = None - for p in current.parents: - # Look for a 'src' directory that contains the 'sage' package - if p.name == "src" and (p / "sage").exists(): - src_path = p - break - if src_path is not None: - sys.path.insert(0, str(src_path)) - from sage.tools.dev.issues.config import IssuesConfig as Config - else: - # Last resort: try relative import if package context exists - from ..config import IssuesConfig as Config + # Preferred: absolute import via installed package + from sage_github.config import IssuesConfig as Config +except ImportError: + # Fallback: add parent directory to path + sys.path.insert(0, str(Path(__file__).parent.parent)) + from config import IssuesConfig as Config def find_token(): @@ -146,7 +135,7 @@ def write_outputs(self, teams_data): usernames_file = self.meta_dir / "team_usernames.txt" lines = [f"# generated: {datetime.now().isoformat()}"] all_usernames = set() - for slug, info in teams_data.items(): + for _slug, info in teams_data.items(): lines.append(f"\n## {info.get('name')}") for m in info.get("members", []): username = m.get("username") diff --git a/src/sage_github/helpers/github_helper.py b/src/sage_github/helpers/github_helper.py index 93b69b1..3fbb93a 100644 --- a/src/sage_github/helpers/github_helper.py +++ b/src/sage_github/helpers/github_helper.py @@ -5,9 +5,9 @@ """ import json +from pathlib import Path import sys import time -from pathlib import Path import requests diff --git a/src/sage_github/helpers/organize_issues.py b/src/sage_github/helpers/organize_issues.py index 631c748..c3ff9e1 100644 --- a/src/sage_github/helpers/organize_issues.py +++ b/src/sage_github/helpers/organize_issues.py @@ -16,10 +16,10 @@ """ import argparse -import json -import sys from datetime import UTC, datetime, timedelta +import json from pathlib import Path +import sys import requests diff --git a/src/sage_github/helpers/sync_issues.py b/src/sage_github/helpers/sync_issues.py index eaa7ec4..336cbdb 100755 --- a/src/sage_github/helpers/sync_issues.py +++ b/src/sage_github/helpers/sync_issues.py @@ -20,15 +20,15 @@ """ import argparse +from datetime import datetime import json +from pathlib import Path import re import sys import time -from datetime import datetime -from pathlib import Path -import requests from github_helper import GitHubProjectManager +import requests SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR.parent)) # Add parent directory to path @@ -42,9 +42,8 @@ except ImportError: # 如果相对导入失败,使用绝对导入 sys.path.insert(0, str(SCRIPT_DIR.parent)) - from issue_data_manager import IssueDataManager - from config import IssuesConfig + from issue_data_manager import IssueDataManager # Import github_helper directly sys.path.insert(0, str(SCRIPT_DIR)) diff --git a/src/sage_github/issue_data_manager.py b/src/sage_github/issue_data_manager.py index 788b1ef..90cbc9c 100644 --- a/src/sage_github/issue_data_manager.py +++ b/src/sage_github/issue_data_manager.py @@ -4,11 +4,11 @@ 实现单一数据源 + 视图分离的新架构 """ +from datetime import datetime import json import os -import re -from datetime import datetime from pathlib import Path +import re from typing import Any diff --git a/src/sage_github/manager.py b/src/sage_github/manager.py index 4021fad..1c25712 100644 --- a/src/sage_github/manager.py +++ b/src/sage_github/manager.py @@ -5,12 +5,12 @@ and calls helper scripts from helpers/ when available. """ +from datetime import datetime import json import os +from pathlib import Path import subprocess import sys -from datetime import datetime -from pathlib import Path from typing import Any from .config import IssuesConfig @@ -287,6 +287,449 @@ def _generate_statistics(self, issues: list[dict[str, Any]]) -> dict[str, Any]: return stats + def list_issues( + self, + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + sort_by: str = "created", + reverse: bool = True, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """ + 列出和过滤Issues + + Args: + state: 状态过滤 (all/open/closed) + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + sort_by: 排序字段 (created/updated/comments/number) + reverse: 是否降序 + limit: 限制结果数量 + + Returns: + 过滤后的Issues列表 + """ + from sage_github.helpers.filter_issues import IssuesFilter + + issues = self.load_issues() + if not issues: + return [] + + # 使用过滤器 + filter_tool = IssuesFilter(issues) + filtered_issues = filter_tool.apply_filters( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone, + author=author, + sort_by=sort_by, + reverse=reverse, + limit=limit, + ) + + return filtered_issues + + def export_issues( + self, + output_path: Path | str, + format: str = "csv", + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + template: str = "default", + ) -> bool: + """ + 导出Issues到文件 + + Args: + output_path: 输出文件路径 + format: 导出格式 (csv/json/markdown) + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + template: Markdown模板 (default/roadmap/report) + + Returns: + 是否成功 + """ + from sage_github.helpers.export_issues import IssuesExporter + + # 获取过滤后的Issues + issues = self.list_issues( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone, + author=author, + sort_by="created", + reverse=True, + ) + + if not issues: + print("⚠️ 没有符合条件的Issues") + return False + + # 转换路径 + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # 导出 + exporter = IssuesExporter(issues) + + if format == "csv": + success = exporter.export_to_csv(output_path) + elif format == "json": + success = exporter.export_to_json(output_path, pretty=True) + elif format == "markdown": + success = exporter.export_to_markdown(output_path, template=template) + else: + print(f"❌ 不支持的格式: {format}") + return False + + if success: + print(f"✅ 导出成功: {output_path}") + print(f"📊 导出了 {len(issues)} 个Issues") + return success + + def batch_close( + self, + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量关闭Issues + + Args: + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计 + """ + from sage_github.helpers.batch_operations import BatchOperations + + # 获取匹配的Issues + issues = self.list_issues( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone, + author=author, + ) + + # 执行批量关闭 + batch_ops = BatchOperations( + owner=self.config.GITHUB_OWNER, + repo=self.config.GITHUB_REPO, + token=self.config.github_token, + ) + return batch_ops.close_issues(issues, dry_run=dry_run, auto_confirm=auto_confirm) + + def batch_add_labels( + self, + add_labels: list[str], + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量添加标签 + + Args: + add_labels: 要添加的标签列表 + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计 + """ + from sage_github.helpers.batch_operations import BatchOperations + + issues = self.list_issues( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone, + author=author, + ) + + batch_ops = BatchOperations( + owner=self.config.GITHUB_OWNER, + repo=self.config.GITHUB_REPO, + token=self.config.github_token, + ) + return batch_ops.add_labels(issues, add_labels, dry_run=dry_run, auto_confirm=auto_confirm) + + def batch_remove_labels( + self, + remove_labels: list[str], + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量移除标签 + + Args: + remove_labels: 要移除的标签列表 + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计 + """ + from sage_github.helpers.batch_operations import BatchOperations + + issues = self.list_issues( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone, + author=author, + ) + + batch_ops = BatchOperations( + owner=self.config.GITHUB_OWNER, + repo=self.config.GITHUB_REPO, + token=self.config.github_token, + ) + return batch_ops.remove_labels( + issues, remove_labels, dry_run=dry_run, auto_confirm=auto_confirm + ) + + def batch_assign( + self, + assignees: list[str], + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone: str | None = None, + author: str | None = None, + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量分配Issues + + Args: + assignees: 负责人列表 + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone: 里程碑过滤 + author: 创建者过滤 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计 + """ + from sage_github.helpers.batch_operations import BatchOperations + + issues = self.list_issues( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone, + author=author, + ) + + batch_ops = BatchOperations( + owner=self.config.GITHUB_OWNER, + repo=self.config.GITHUB_REPO, + token=self.config.github_token, + ) + return batch_ops.assign_issues( + issues, assignees, dry_run=dry_run, auto_confirm=auto_confirm + ) + + def batch_set_milestone( + self, + milestone: str, + state: str = "all", + labels: list[str] | None = None, + assignee: str | None = None, + milestone_filter: str | None = None, + author: str | None = None, + dry_run: bool = False, + auto_confirm: bool = False, + ) -> dict[str, Any]: + """批量设置里程碑 + + Args: + milestone: 要设置的里程碑 + state: 状态过滤 + labels: 标签过滤 + assignee: 负责人过滤 + milestone_filter: 里程碑过滤 + author: 创建者过滤 + dry_run: 是否为预览模式 + auto_confirm: 是否自动确认 + + Returns: + 操作结果统计 + """ + from sage_github.helpers.batch_operations import BatchOperations + + issues = self.list_issues( + state=state, + labels=labels, + assignee=assignee, + milestone=milestone_filter, + author=author, + ) + + batch_ops = BatchOperations( + owner=self.config.GITHUB_OWNER, + repo=self.config.GITHUB_REPO, + token=self.config.github_token, + ) + return batch_ops.set_milestone( + issues, milestone, dry_run=dry_run, auto_confirm=auto_confirm + ) + + def summarize_issue( + self, issue_number: int, api_provider: str = "openai", max_length: int = 200 + ) -> dict[str, Any] | None: + """生成 Issue 摘要 + + Args: + issue_number: Issue 编号 + api_provider: API 提供商 (openai/claude) + max_length: 最大摘要长度 + + Returns: + 包含摘要的字典,如果失败返回 None + """ + from sage_github.helpers.ai_helper import AIHelper + + # 加载 Issues + issues = self.load_issues() + issue = next((i for i in issues if i.get("number") == issue_number), None) + + if not issue: + print(f"❌ 未找到 Issue #{issue_number}") + return None + + # 生成摘要 + ai = AIHelper(api_provider=api_provider) + summary = ai.summarize_issue(issue, max_length=max_length) + + if summary: + return { + "number": issue_number, + "title": issue.get("title"), + "summary": summary, + "url": issue.get("html_url"), + } + + return None + + def detect_duplicates(self, threshold: float = 0.7) -> list[dict[str, Any]]: + """检测重复的 Issues + + Args: + threshold: 相似度阈值 (0-1) + + Returns: + 重复对列表 + """ + from sage_github.helpers.ai_helper import AIHelper + + issues = self.load_issues() + if not issues: + print("❌ 没有可用的 Issues") + return [] + + ai = AIHelper(silent=True) # 不需要 API,使用静默模式 + duplicates = ai.detect_duplicates(issues, threshold=threshold) + + results = [] + for issue1, issue2, similarity in duplicates: + results.append( + { + "issue1": { + "number": issue1.get("number"), + "title": issue1.get("title"), + "url": issue1.get("html_url"), + }, + "issue2": { + "number": issue2.get("number"), + "title": issue2.get("title"), + "url": issue2.get("html_url"), + }, + "similarity": round(similarity, 2), + } + ) + + return results + + def suggest_labels_for_issue(self, issue_number: int) -> dict[str, Any] | None: + """为 Issue 推荐标签 + + Args: + issue_number: Issue 编号 + + Returns: + 包含推荐标签的字典,如果失败返回 None + """ + from sage_github.helpers.ai_helper import AIHelper + + issues = self.load_issues() + issue = next((i for i in issues if i.get("number") == issue_number), None) + + if not issue: + print(f"❌ 未找到 Issue #{issue_number}") + return None + + ai = AIHelper(silent=True) # 标签建议不需要 API + suggested = ai.suggest_labels(issue) + + existing_labels = [label.get("name") for label in issue.get("labels", [])] + + return { + "number": issue_number, + "title": issue.get("title"), + "existing_labels": existing_labels, + "suggested_labels": suggested, + "new_labels": [label for label in suggested if label not in existing_labels], + "url": issue.get("html_url"), + } + def show_statistics(self) -> bool: """显示Issues统计信息""" print("📊 显示Issues统计信息...") diff --git a/src/sage_github/tests.py b/src/sage_github/tests.py index bba3152..de01ec2 100644 --- a/src/sage_github/tests.py +++ b/src/sage_github/tests.py @@ -4,11 +4,11 @@ 基于原始test_issues_manager.sh的Python实现 """ +from datetime import datetime import os +from pathlib import Path import shutil import sys -from datetime import datetime -from pathlib import Path from rich.console import Console from rich.panel import Panel @@ -275,7 +275,7 @@ def generate_report(self, passed: int, total: int): # 检查是否有不可接受的失败 critical_failures = [] - for test_name, result, error in self.test_results: + for test_name, result, _error in self.test_results: if not result and test_name in ["配置验证", "文件操作"]: critical_failures.append(test_name) diff --git a/tests/__init__.py b/tests/__init__.py index d7f473b..b1cad1b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,7 +1,7 @@ """Test package""" -import sys from pathlib import Path +import sys # Add src to path for testing src_path = Path(__file__).parent.parent / "src" diff --git a/tests/test_basic.py b/tests/test_basic.py index 03c7c8e..afca792 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,6 +1,7 @@ """Basic tests for GitHub Issues Manager""" import pytest + from sage_github import IssuesConfig, IssuesManager diff --git a/tests/test_config.py b/tests/test_config.py index 375837c..14270a8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest + from sage_github.config import IssuesConfig