Main dev - #1
Main dev#1
Conversation
- Add list command with filtering (state, label, assignee, milestone, author) - Add sorting and limit options - Implement config.json for default settings (priority: config.json < .env < env vars < params) - Auto-load .env files from multiple locations - Add python-dotenv dependency - Move documentation files to docs/ directory - Fix linting issues Tested with 1304 SAGE issues successfully
- Update import from sage.tools.dev.issues to sage_github - Team command now works correctly - Successfully loads team info (26 members from 3 teams)
- Add IssuesExporter class with three formats: - CSV: Full issue data with all fields - JSON: Structured data for API integration - Markdown: Three templates (default/roadmap/report) - Add export_issues() method to GitHubManager - Add export CLI command with filtering support - Update documentation to mark export as completed - Add B008 to ruff ignore (required for Typer) Tests: - CSV export: 128 open issues → 11.08 KB - JSON export: 209 closed bugs → 63.34 KB - Markdown roadmap: 128 open issues → 7.76 KB All formats tested and working correctly.
- Add BatchOperations class for bulk issue management: - batch-close: Close multiple issues at once - batch-label: Add/remove labels in bulk - batch-assign: Assign issues to users - batch-milestone: Set milestones for multiple issues - Key Features: - Dry-run mode (--dry-run) to preview changes - Confirmation prompts (skip with --yes) - Rich preview tables (first 20 issues) - Progress bars for operations - Full filtering support (reuses list filters) - Direct GitHub REST API integration - Implementation: - BatchOperations class (454 lines) with requests - 5 batch methods in manager.py - 4 CLI commands with comprehensive help - Testing: - Dry-run tested with 128 SAGE open issues - Preview table displays correctly - All filters working - Documentation: - Updated copilot-instructions.md (mark batch as implemented) - Updated MISSING_FEATURES.md (status 🟢 Completed) - Created IMPLEMENTATION_PROGRESS.md (project summary)
- Remove deprecated settings: - ruff.lint.run (auto-detected now) - ruff.format.args (use ruff.toml) - ruff.lint.args (use ruff.toml) - Add new settings: - ruff.configurationPreference: filesystemFirst (auto-detect ruff.toml) - ruff.path: use 'ruff' command References: - Migration guide: https://docs.astral.sh/ruff/editors/migration/ - Discussion: astral-sh/ruff#15991
AI Features (Task 4): - Add AIHelper class for AI-powered analysis - summarize: Generate AI summaries (OpenAI/Claude API) - detect-duplicates: Find duplicates using text similarity - suggest-labels: Recommend labels based on keywords - Three new CLI commands: - github-manager summarize <issue> - github-manager detect-duplicates - github-manager suggest-labels <issue> - Silent mode for non-API operations - Rich table output for duplicates - Tested: 592 duplicate pairs found in 1304 SAGE issues Command Naming Consistency (Task 5): - Rename 'stats' to 'analytics' for consistency - Keep 'stats' as hidden alias with deprecation warning - Update all documentation (README, FAQ, QUICK_START, etc.) - Add comprehensive docstring for analytics command Documentation: - Updated MISSING_FEATURES.md (Tasks 4 & 5 completed) - Fixed references across all docs - Added usage examples Files Changed: - src/sage_github/helpers/ai_helper.py (new, 308 lines) - src/sage_github/manager.py (3 new AI methods) - src/sage_github/cli.py (3 AI commands, analytics rename) - All documentation files updated
There was a problem hiding this comment.
Pull request overview
This PR implements major new functionality for the sage-github-manager project, including list/filter, export, batch operations, and AI-powered features. It also introduces configuration improvements with .env file support and updates documentation to reflect the new capabilities.
Key Changes:
- Adds multi-layer configuration system with .env file support via python-dotenv
- Implements issue filtering, exporting (CSV/JSON/Markdown), and batch operations (close, label, assign, milestone)
- Adds AI helper functionality for issue summarization, duplicate detection, and label suggestions
- Reorganizes imports alphabetically across multiple files for consistency
- Updates documentation and examples to reflect new features
Reviewed changes
Copilot reviewed 37 out of 45 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/sage_github/config.py | Adds .env loading and config.json support with multi-layer priority system |
| src/sage_github/manager.py | Adds methods for list_issues, export_issues, batch operations, and AI features |
| src/sage_github/cli.py | Implements new CLI commands: list, export, batch-*, summarize, detect-duplicates, suggest-labels |
| src/sage_github/helpers/filter_issues.py | New module: flexible issue filtering with multiple criteria |
| src/sage_github/helpers/export_issues.py | New module: export to CSV/JSON/Markdown with three templates |
| src/sage_github/helpers/batch_operations.py | New module: batch GitHub API operations with dry-run and confirmation |
| src/sage_github/helpers/ai_helper.py | New module: AI-powered analysis using OpenAI/Claude APIs |
| pyproject.toml | Adds python-dotenv dependency |
| ruff.toml | Adds B008 to ignore list for Typer compatibility |
| docs/* | Updates all documentation to reflect new commands and features |
| tests/* | Import reorganization for consistency |
| @app.command("list") | ||
| def list_issues( | ||
| state: str = "open", | ||
| label: list[str] | None = None, |
There was a problem hiding this comment.
Typer default argument warning: Using mutable default list[str] | None = None is correct, but the actual parameter label should use typer.Option(None) instead of None as the direct default to avoid B008 warnings. While ruff.toml ignores B008 globally, it's better to use Typer's patterns correctly.
Change to: label: list[str] = typer.Option([], "--label", "-l", help="...")
This makes the intent clearer and follows Typer best practices.
| label: list[str] | None = None, | |
| label: list[str] = typer.Option( | |
| [], | |
| "--label", | |
| "-l", | |
| help="按标签过滤,可多次使用此选项指定多个标签", | |
| ), |
| 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}") |
There was a problem hiding this comment.
This violates the project's critical "NO FALLBACK LOGIC" principle. When config file loading fails (line 153), the code catches the exception and prints a warning, then continues by returning default config. This silently hides configuration errors.
According to the project guidelines, configuration loading should fail fast and loud. If a config.json file exists but is malformed, the exception should propagate to alert the user immediately rather than falling back to defaults.
| try: | ||
| self._update_issue(issue["number"], state="closed") | ||
| success_count += 1 | ||
| except Exception as e: |
There was a problem hiding this comment.
Broad exception catching without proper error handling. This catches all exceptions (including KeyboardInterrupt, SystemExit) when updating an issue. The error is printed but the operation continues, which could hide serious issues like network failures or authentication problems.
Consider catching only specific exceptions (requests.RequestException, KeyError for missing 'number' key) and potentially stopping after repeated failures rather than continuing through all issues.
| except Exception as e: | |
| except (requests.RequestException, KeyError) as e: |
| 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]") |
There was a problem hiding this comment.
This pattern of broad exception catching appears throughout the batch operations file (lines 195, 271, 344, 427). All these locations catch Exception broadly without distinguishing between different error types. This makes it difficult to identify systematic issues (like authentication failure or rate limiting) versus individual issue problems.
Consider refactoring to:
- Catch specific exceptions (requests.RequestException, KeyError)
- Detect and handle rate limiting specifically
- Stop after N consecutive failures to avoid hammering the API
- Return more detailed error information in the result dict
| except Exception as e: | ||
| print(f"❌ CSV导出失败: {e}") | ||
| return False |
There was a problem hiding this comment.
Broad exception catching that masks the actual error. The function catches all exceptions (including KeyboardInterrupt, SystemExit) and only prints a generic message. This makes debugging difficult when exports fail.
Consider:
- Catching specific exceptions (IOError, OSError, csv.Error)
- Letting other exceptions propagate
- Including more context in the error message (e.g., file path, number of issues being exported)
| 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}") |
There was a problem hiding this comment.
Same broad exception catching issue as line 124-126. This pattern appears in export_to_json (152), export_to_markdown (183), and is a systemic problem throughout the export module.
| 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") |
There was a problem hiding this comment.
The instructions suggest installing packages with pip install, which violates the project's "ALWAYS USE pyproject.toml" principle. Dependencies should be declared in pyproject.toml under optional-dependencies (e.g., an "ai" extras group) rather than instructing users to manually install them.
Suggested fix: Add to pyproject.toml:
[project.optional-dependencies]
ai = ["openai>=1.0.0", "anthropic>=0.7.0"]
Then update the message to: "Install with: pip install sage-github-manager[ai]"
| 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 |
There was a problem hiding this comment.
Broad exception catching in AI API calls. Lines 112, 139, and 157 all catch Exception broadly. These should catch specific API exceptions (openai.APIError, anthropic.APIError, etc.) to distinguish between transient network issues, authentication failures, rate limiting, and other errors.
This makes debugging difficult and prevents implementing appropriate retry logic for different error types.
No description provided.