diff --git a/README.md b/README.md index e84ef52..3e330df 100644 --- a/README.md +++ b/README.md @@ -378,9 +378,25 @@ gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 - gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes +# Export filtered issues to CSV for offline triage or reports +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv + # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" +# Reply to a comment with attachments and mentions +gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "Thanks, please check the log" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob + +# List comments only, or include operation records with --category all +gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment --keyword fixed + +# Update or delete a comment +gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 456 -b "Updated comment" +gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + +# List replies under a comment +gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/README.zh-CN.md b/README.zh-CN.md index 02927aa..41729dc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -113,11 +113,9 @@ | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | -| 🔔 通知 | 列出消息、标记已读、删除消息、发送 @ 提及 | | 📖 Wiki | 列出、查看、创建、更新、删除 Wiki 页面 | | 🔍 搜索 | 搜索仓库、用户 | | 📊 数据集 | 按项目查询科研数据集 | -| 📄 文件 | 无需克隆即可查看、搜索、创建、更新、删除仓库文件 | | 👤 用户 | 查看用户资料和信息 | | 📊 画像 | 用户开发能力、角色定位、专业定位、近期活动、贡献热力图统计 | | 📋 项目管理 | Sprint 管理、看板、周报 | @@ -287,30 +285,6 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` -### 通知管理 - -```bash -# 列出当前认证用户的未读通知 -gitlink-cli notification +list --status unread --limit 20 - -# 列出指定用户的 @ 我消息 -gitlink-cli notification +list --user zhangsan --type atme --status unread - -# 预览并将指定消息标记为已读 -gitlink-cli notification +read --ids 740214,740213 --dry-run -gitlink-cli notification +read --ids 740214,740213 --yes - -# 预览将全部未读系统通知标记为已读 -gitlink-cli notification +read --type notification --all-unread --dry-run - -# 预览删除指定消息 -gitlink-cli notification +delete --ids 740214,740213 --dry-run - -# 为 Issue、PR 或 Journal 目标发送 @ 提及消息 -gitlink-cli notification +send-atme --receivers alice,bob \ - --atmeable-type Issue --atmeable-id 123 --dry-run -``` - ### Wiki 管理 ```bash @@ -390,12 +364,24 @@ gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 - gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes +# 将筛选后的 Issue 导出为 CSV,便于离线分析或生成周报 +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv + # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" -# 列出 Issue 动态记录或评论活动 -gitlink-cli issue +journals --owner Gitlink --repo forgeplus --number 123 --page 1 --limit 50 -gitlink-cli issue +activity --owner Gitlink --repo forgeplus --number 123 --category comment +# 回复评论并携带附件和 @ 用户 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "请查看日志" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob + +# 列出评论;需要操作记录时可传 --category all +gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment --keyword fixed + +# 更新或删除评论 +gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 456 -b "更新后的评论" +gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + +# 列出评论下的回复 +gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus @@ -494,49 +480,6 @@ gitlink-cli release +update --owner Gitlink --repo forgeplus -i -b gitlink-cli release +delete --owner Gitlink --repo forgeplus -i --dry-run ``` -### 健康度诊断 - -`gitlink health diagnose` 从 5 个维度分析仓库健康度:文档、许可证、社区、成熟度、CI/CD。 - -```bash -# 基础诊断(文本输出) -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli - -# JSON 输出,便于脚本和 AI Agent 处理 -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --format json - -# Markdown 输出,用于报告 -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --format markdown - -# 详细模式,显示完整分解 -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --verbose - -# 自定义数据库路径,用于历史追踪 -gitlink-cli health diagnose --owner Gitlink --repo gitlink-cli --db ./health.db -``` - -**评分维度(总分 100 分):** - -| 维度 | 权重 | 评估标准 | -|------|------|----------| -| 文档 | 20 | README 质量、贡献指南、行为准则 | -| 许可证 | 15 | 许可证存在性及 OSI 认证 | -| 社区 | 25 | 贡献者数量、活跃度、巴士系数 | -| 成熟度 | 20 | 发布版本、版本稳定性、项目年龄 | -| CI/CD | 20 | 构建成功率、流水线配置 | - -**健康状态阈值:** - -- `good` (≥70%):健康项目,维护活跃 -- `warning` (≥40%):部分领域需要关注 -- `critical` (<40%):需要立即改进 - -**输出格式:** - -- `text`(默认):人类可读的摘要和建议 -- `json`:结构化数据,便于脚本和 AI Agent 处理 -- `markdown`:格式化报告,用于文档 - ### 流水线管理 ```bash @@ -623,29 +566,6 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --ye ``` > 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。 -### 文件操作 - -`file` 无需克隆即可读写仓库文件内容,非常适合需要读取或修改单个文件的 AI Agent。目录列表和 README 查看请使用 `repo +tree` 和 `repo +readme`。 - -```bash -# 查看文件(--raw 仅输出解码后的文件内容,方便管道处理) -gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md -gitlink-cli file +view --owner Gitlink --repo forgeplus --path README.md --raw > README.md - -# 按文件名搜索 -gitlink-cli file +search --owner Gitlink --repo forgeplus --keyword controller - -# 创建 / 更新文件(内容可内联或来自本地文件) -gitlink-cli file +create --owner me --repo proj --path docs/note.md -c "# 笔记" -b master -m "add note" -gitlink-cli file +update --owner me --repo proj --path docs/note.md --content-file note.md -b master - -# 提交到从 --branch 新建的分支 -gitlink-cli file +update --owner me --repo proj --path docs/note.md -c "..." -b master --new-branch feature/docs - -# 删除文件 -gitlink-cli file +delete --owner me --repo proj --path docs/note.md -b master -m "remove note" -``` - ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: @@ -719,7 +639,6 @@ git push gitlink | `gitlink-user` | 用户管理(个人信息等) | | `gitlink-pm` | 项目管理(Sprint、看板、周报等) | | `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) | -| `gitlink-health` | 项目健康度诊断(5 维度评分:文档、许可证、社区、成熟度、CI/CD) | ## 项目结构 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0c1fbbb..e47c41c 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -34,7 +34,6 @@ "cmd.dataset.view.short": "View a repository's dataset", "cmd.doctor.long": "Run local diagnostics for gitlink-cli configuration, authentication, repository context and API connectivity.", "cmd.doctor.short": "Diagnose gitlink-cli environment problems", - "cmd.issue.activity.short": "List issue activity events", "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", @@ -44,16 +43,12 @@ "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", "cmd.issue.create.short": "Create a new issue", - "cmd.issue.journals.short": "List issue journals", + "cmd.issue.export.long": "Export filtered issues across multiple pages to CSV, JSON, or Markdown.\n\nThe command reuses issue list filters and is read-only, making it suitable for reports, migration checklists, offline triage, and AI analysis inputs.\n\nExamples:\n gitlink-cli issue +export --state open --export-format csv --output issues.csv\n gitlink-cli issue +export --state all --keyword bug --fields number,title,status,assignees,updated_at,url --export-format markdown\n gitlink-cli issue +export --tag-ids 1,2 --max 100 --export-format json --output issues.json", + "cmd.issue.export.short": "Export filtered issues to CSV, JSON, or Markdown", "cmd.issue.list.short": "List issues", "cmd.issue.short": "Issue operations", "cmd.issue.update.short": "Update an issue", "cmd.issue.view.short": "View issue details", - "cmd.notification.delete.short": "Delete user messages", - "cmd.notification.list.short": "List user messages and notifications", - "cmd.notification.read.short": "Mark user messages as read", - "cmd.notification.send_atme.short": "Send @ mention messages", - "cmd.notification.short": "Notification and message operations", "cmd.org.create.short": "Create an organization", "cmd.org.info.short": "Show organization details", "cmd.org.list.short": "List organizations", @@ -119,7 +114,6 @@ "error.config.save_failed": "failed to save config: {message}", "error.dataset.delete_confirm": "dataset attachment deletion is destructive; run --dry-run first, then pass --yes to confirm", "error.missing_required_flag": "required flag --{name} is missing", - "error.notification.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.profile.user_required": "could not determine target user; pass --user or run gitlink-cli auth login", "error.unsupported_language": "unsupported language: {lang}", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", @@ -166,7 +160,11 @@ "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", "flag.issue.body": "Issue description", - "flag.issue.journal_category": "Journal category filter, for example: comment", + "flag.issue.export_fields": "Comma-separated fields to export", + "flag.issue.export_format": "Export format: csv, json, markdown", + "flag.issue.export_limit": "Items per page, capped at 100", + "flag.issue.export_max": "Maximum issues to export; 0 means no explicit cap", + "flag.issue.export_output": "Write export content to a file instead of stdout", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", "flag.issue.milestone": "Milestone ID", @@ -182,16 +180,6 @@ "flag.issue.title": "Issue title", "flag.lang": "Display language", "flag.limit": "Items per page", - "flag.notification.all_unread": "Mark all unread messages of the selected type as read", - "flag.notification.atmeable_id": "@ message target object ID", - "flag.notification.atmeable_type": "@ message target type: Journal, Issue, or PullRequest", - "flag.notification.dry_run": "Preview the message request without changing remote state", - "flag.notification.ids": "Comma-separated message IDs", - "flag.notification.receivers": "Comma-separated receiver logins", - "flag.notification.status": "Filter status: unread, read, 1, or 2", - "flag.notification.type": "Message type: notification or atme", - "flag.notification.user": "Target user login (defaults to the authenticated user)", - "flag.notification.yes": "Confirm the remote message operation", "flag.org.id": "Organization ID", "flag.org.id_or_login": "Organization ID or login", "flag.org.name": "Organization name", @@ -286,111 +274,5 @@ "success.config.set": "✓ {key} = {value}", "warning.auth.not_logged_in": "✗ Not logged in", "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", - "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)", - "cmd.health.diagnose.short": "Diagnose project health", - "cmd.health.diagnose.long": "Perform comprehensive project health diagnosis, including documentation completeness, license compliance, community activity, project maturity, and CI/CD configuration.", - "cmd.health.diagnose.format": "Output format: text, json, markdown", - "cmd.health.diagnose.verbose": "Show detailed diagnosis information", - "cmd.health.diagnose.db": "Database path (for storing diagnosis history)", - "prompt.diagnosing": "Diagnosing project health...", - "output.report_title": "Project Health Diagnosis Report", - "output.project": "Project", - "output.total_score": "Total Score", - "output.diagnosis_details": "Diagnosis Details", - "output.suggestions": "Improvement Suggestions", - "table.category": "Category", - "table.score": "Score", - "table.status": "Status", - "output.category_documentation": "Documentation Completeness", - "output.category_license": "License Compliance", - "output.category_community": "Community Activity", - "output.category_maturity": "Project Maturity", - "output.category_ci": "CI/CD Configuration", - "output.doc_readme_found": "✓ README file exists", - "output.doc_readme_comprehensive": "✓ README is comprehensive with multiple important sections", - "output.doc_readme_basic": "⚠ README is basic, consider adding more sections", - "output.doc_readme_minimal": "✗ README content is too minimal", - "output.doc_readme_empty": "✗ README file is empty", - "output.doc_readme_missing": "✗ Missing README file", - "output.doc_description_found": "✓ Project description is set", - "output.doc_description_missing": "✗ Missing project description", - "output.license_found": "✓ License", - "output.license_well_known": "✓ Using a well-known open source license", - "output.license_custom": "⚠ Using a custom license", - "output.license_missing": "✗ Missing license", - "output.license_unknown": "⚠ Unable to retrieve license information", - "output.community_contributors_many": "✓ Many contributors, active community", - "output.community_contributors_good": "✓ Good number of contributors", - "output.community_contributors_few": "⚠ Few contributors", - "output.community_contributors_solo": "✗ Only one contributor", - "output.community_popular": "✓ Project is popular with high Star/Fork count", - "output.community_moderate": "⚠ Project has moderate attention", - "output.community_growing": "⚠ Project is still growing", - "output.community_issues_active": "✓ Active issues, high community engagement", - "output.community_issues_moderate": "⚠ Moderate number of issues", - "output.community_issues_few": "⚠ Few issues", - "output.community_issues_none": "✗ No issues", - "output.maturity_branches_good": "✓ Branch management is well established", - "output.maturity_branches_basic": "⚠ Branch management is basic", - "output.maturity_branches_minimal": "✗ Branch management is insufficient", - "output.maturity_releases_many": "✓ Many release versions", - "output.maturity_releases_some": "✓ Multiple release versions", - "output.maturity_releases_initial": "⚠ Only one initial version", - "output.maturity_releases_none": "✗ No release versions", - "output.maturity_pr_active": "✓ Active pull requests", - "output.maturity_pr_some": "⚠ Moderate number of pull requests", - "output.maturity_pr_few": "✗ Few pull requests", - "output.ci_configured": "✓ CI/CD is configured", - "output.ci_stable": "✓ High build success rate, CI is stable", - "output.ci_moderate": "⚠ Moderate build success rate", - "output.ci_unstable": "✗ Low build success rate, CI is unstable", - "output.ci_not_configured": "✗ CI/CD not configured", - "output.suggest_readme_add": "Add a README file", - "output.suggest_readme_add_desc": "The project is missing a README file, which is essential documentation for open source projects.", - "output.suggest_readme_action1": "Create a README.md file", - "output.suggest_readme_action2": "Add project introduction and installation instructions", - "output.suggest_readme_action3": "Include usage examples and contribution guidelines", - "output.suggest_readme_improve": "Improve README content", - "output.suggest_readme_improve_desc": "The README is missing some important sections.", - "output.suggest_section_install": "Add installation/quick start section", - "output.suggest_section_usage": "Add usage examples section", - "output.suggest_section_contrib": "Add contribution guidelines section", - "output.suggest_section_license": "Add license information", - "output.suggest_description_add": "Add project description", - "output.suggest_description_add_desc": "The project is missing a description.", - "output.suggest_description_action1": "Add a description in project settings", - "output.suggest_license_add": "Add an open source license", - "output.suggest_license_add_desc": "The project is missing a license, which affects usage and contributions.", - "output.suggest_license_action1": "Choose an appropriate open source license (MIT, Apache-2.0, GPL, etc.)", - "output.suggest_license_action2": "Configure the license in project settings", - "output.suggest_license_action3": "Document the license in README", - "output.suggest_license_standard": "Use a standard open source license", - "output.suggest_license_standard_desc": "Currently using a custom license, consider using a standard open source license.", - "output.suggest_license_standard_action": "Consider switching to MIT, Apache-2.0, or GPL standard licenses", - "output.suggest_community_grow": "Expand community participation", - "output.suggest_community_grow_desc": "The project has few contributors, consider attracting more developers.", - "output.suggest_community_action1": "Add contribution guidelines in README", - "output.suggest_community_action2": "Create Good First Issue labels to guide new contributors", - "output.suggest_community_action3": "Promote the project in the community", - "output.suggest_issues_encourage": "Encourage community feedback", - "output.suggest_issues_encourage_desc": "The project has no issues, consider encouraging user feedback.", - "output.suggest_issues_action1": "Create issue templates", - "output.suggest_issues_action2": "Explain how to submit issues in README", - "output.suggest_branches_setup": "Establish branch management strategy", - "output.suggest_branches_setup_desc": "Project branch management is insufficient, consider establishing a branch strategy.", - "output.suggest_branches_action1": "Create a develop branch for development", - "output.suggest_branches_action2": "Set up branch protection rules", - "output.suggest_release_create": "Create release versions", - "output.suggest_release_create_desc": "The project has no release versions, consider creating the first version.", - "output.suggest_release_action1": "Create a v1.0.0 or v0.1.0 tag", - "output.suggest_release_action2": "Write release notes", - "output.suggest_ci_setup": "Configure CI/CD", - "output.suggest_ci_setup_desc": "The project has no CI/CD configured, consider adding automated build and test.", - "output.suggest_ci_action1": "Configure GitHub Actions or GitLink CI", - "output.suggest_ci_action2": "Add automated tests", - "output.suggest_ci_action3": "Configure code quality checks", - "output.suggest_ci_improve": "Improve CI stability", - "output.suggest_ci_improve_desc": "Build success rate is low, consider improving CI configuration.", - "output.suggest_ci_improve_action1": "Check failed build logs", - "output.suggest_ci_improve_action2": "Fix failing test issues" + "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" } diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b878325..c8df838 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -34,7 +34,6 @@ "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", - "cmd.issue.activity.short": "列出议题活动事件", "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", @@ -44,16 +43,12 @@ "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", "cmd.issue.create.short": "创建新议题", - "cmd.issue.journals.short": "列出议题动态记录", + "cmd.issue.export.long": "跨页导出筛选后的议题,支持 CSV、JSON 或 Markdown。\n\n命令复用议题列表筛选条件,并且只读取远端数据,适合生成周报、迁移清单、离线排查表和 AI 分析输入。\n\n示例:\n gitlink-cli issue +export --state open --export-format csv --output issues.csv\n gitlink-cli issue +export --state all --keyword bug --fields number,title,status,assignees,updated_at,url --export-format markdown\n gitlink-cli issue +export --tag-ids 1,2 --max 100 --export-format json --output issues.json", + "cmd.issue.export.short": "导出筛选后的议题为 CSV、JSON 或 Markdown", "cmd.issue.list.short": "列出议题", "cmd.issue.short": "议题操作", "cmd.issue.update.short": "更新议题", "cmd.issue.view.short": "查看议题详情", - "cmd.notification.delete.short": "删除用户消息", - "cmd.notification.list.short": "列出用户消息和通知", - "cmd.notification.read.short": "将用户消息标记为已读", - "cmd.notification.send_atme.short": "发送 @ 提及消息", - "cmd.notification.short": "通知与消息操作", "cmd.org.create.short": "创建组织", "cmd.org.info.short": "显示组织详情", "cmd.org.list.short": "列出组织", @@ -119,7 +114,6 @@ "error.config.save_failed": "保存配置失败:{message}", "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", - "error.notification.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.profile.user_required": "无法确定目标用户;请通过 --user 指定,或先运行 gitlink-cli auth login 登录", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", @@ -166,7 +160,11 @@ "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", - "flag.issue.journal_category": "动态记录分类筛选,例如:comment", + "flag.issue.export_fields": "逗号分隔的导出字段", + "flag.issue.export_format": "导出格式:csv、json、markdown", + "flag.issue.export_limit": "每页条目数,上限 100", + "flag.issue.export_max": "最多导出的议题数;0 表示不额外限制", + "flag.issue.export_output": "将导出内容写入文件;不传时输出到 stdout", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", "flag.issue.milestone": "里程碑 ID", @@ -182,16 +180,6 @@ "flag.issue.title": "议题标题", "flag.lang": "显示语言", "flag.limit": "每页条目数", - "flag.notification.all_unread": "将所选类型的全部未读消息标记为已读", - "flag.notification.atmeable_id": "@ 消息目标对象 ID", - "flag.notification.atmeable_type": "@ 消息目标类型:Journal、Issue 或 PullRequest", - "flag.notification.dry_run": "预览消息请求,不修改远端状态", - "flag.notification.ids": "消息 ID,多个用英文逗号分隔", - "flag.notification.receivers": "接收者登录名,多个用英文逗号分隔", - "flag.notification.status": "筛选状态:unread、read、1 或 2", - "flag.notification.type": "消息类型:notification 或 atme", - "flag.notification.user": "目标用户登录名(默认为当前认证用户)", - "flag.notification.yes": "确认执行远端消息操作", "flag.org.id": "组织 ID", "flag.org.id_or_login": "组织 ID 或登录名", "flag.org.name": "组织名称", @@ -286,111 +274,5 @@ "success.config.set": "✓ 已设置 {key} = {value}", "warning.auth.not_logged_in": "✗ 未登录", "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", - "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)", - "cmd.health.diagnose.short": "诊断项目健康度", - "cmd.health.diagnose.long": "对项目进行全面健康度诊断,包括文档完善度、许可证合规性、社区活跃度、项目成熟度和 CI/CD 配置。", - "cmd.health.diagnose.format": "输出格式:text、json、markdown", - "cmd.health.diagnose.verbose": "显示详细诊断信息", - "cmd.health.diagnose.db": "数据库路径(用于存储诊断历史)", - "prompt.diagnosing": "正在诊断项目健康度...", - "output.report_title": "项目健康度诊断报告", - "output.project": "项目", - "output.total_score": "总分", - "output.diagnosis_details": "诊断详情", - "output.suggestions": "改进建议", - "table.category": "类别", - "table.score": "得分", - "table.status": "状态", - "output.category_documentation": "文档完善度", - "output.category_license": "许可证合规性", - "output.category_community": "社区活跃度", - "output.category_maturity": "项目成熟度", - "output.category_ci": "CI/CD 配置", - "output.doc_readme_found": "✓ README 文件存在", - "output.doc_readme_comprehensive": "✓ README 内容完善,包含多个重要章节", - "output.doc_readme_basic": "⚠ README 内容基本,建议补充更多章节", - "output.doc_readme_minimal": "✗ README 内容过于简单", - "output.doc_readme_empty": "✗ README 文件为空", - "output.doc_readme_missing": "✗ 缺少 README 文件", - "output.doc_description_found": "✓ 项目描述已设置", - "output.doc_description_missing": "✗ 缺少项目描述", - "output.license_found": "✓ 许可证", - "output.license_well_known": "✓ 使用知名开源许可证", - "output.license_custom": "⚠ 使用自定义许可证", - "output.license_missing": "✗ 缺少许可证", - "output.license_unknown": "⚠ 无法获取许可证信息", - "output.community_contributors_many": "✓ 贡献者众多,社区活跃", - "output.community_contributors_good": "✓ 贡献者数量良好", - "output.community_contributors_few": "⚠ 贡献者较少", - "output.community_contributors_solo": "✗ 仅有一位贡献者", - "output.community_popular": "✓ 项目受欢迎,Star/Fork 数量高", - "output.community_moderate": "⚠ 项目关注度中等", - "output.community_growing": "⚠ 项目正在成长中", - "output.community_issues_active": "✓ 议题活跃,社区参与度高", - "output.community_issues_moderate": "⚠ 议题数量适中", - "output.community_issues_few": "⚠ 议题较少", - "output.community_issues_none": "✗ 没有议题", - "output.maturity_branches_good": "✓ 分支管理完善", - "output.maturity_branches_basic": "⚠ 分支管理基本", - "output.maturity_branches_minimal": "✗ 分支管理不足", - "output.maturity_releases_many": "✓ 发布版本众多", - "output.maturity_releases_some": "✓ 有多个发布版本", - "output.maturity_releases_initial": "⚠ 仅有一个初始版本", - "output.maturity_releases_none": "✗ 没有发布版本", - "output.maturity_pr_active": "✓ 合并请求活跃", - "output.maturity_pr_some": "⚠ 合并请求数量适中", - "output.maturity_pr_few": "✗ 合并请求较少", - "output.ci_configured": "✓ CI/CD 已配置", - "output.ci_stable": "✓ 构建成功率高,CI 稳定", - "output.ci_moderate": "⚠ 构建成功率中等", - "output.ci_unstable": "✗ 构建成功率低,CI 不稳定", - "output.ci_not_configured": "✗ 未配置 CI/CD", - "output.suggest_readme_add": "添加 README 文件", - "output.suggest_readme_add_desc": "项目缺少 README 文件,这是开源项目的基本文档。", - "output.suggest_readme_action1": "创建 README.md 文件", - "output.suggest_readme_action2": "添加项目简介和安装说明", - "output.suggest_readme_action3": "包含使用示例和贡献指南", - "output.suggest_readme_improve": "完善 README 内容", - "output.suggest_readme_improve_desc": "README 缺少一些重要章节。", - "output.suggest_section_install": "添加安装/快速开始章节", - "output.suggest_section_usage": "添加使用示例章节", - "output.suggest_section_contrib": "添加贡献指南章节", - "output.suggest_section_license": "添加许可证信息", - "output.suggest_description_add": "添加项目描述", - "output.suggest_description_add_desc": "项目缺少描述信息。", - "output.suggest_description_action1": "在项目设置中添加描述", - "output.suggest_license_add": "添加开源许可证", - "output.suggest_license_add_desc": "项目缺少许可证,这会影响他人使用和贡献。", - "output.suggest_license_action1": "选择合适的开源许可证(MIT、Apache-2.0、GPL 等)", - "output.suggest_license_action2": "在项目设置中配置许可证", - "output.suggest_license_action3": "在 README 中说明许可证信息", - "output.suggest_license_standard": "使用标准开源许可证", - "output.suggest_license_standard_desc": "当前使用自定义许可证,建议使用标准开源许可证。", - "output.suggest_license_standard_action": "考虑切换到 MIT、Apache-2.0 或 GPL 等标准许可证", - "output.suggest_community_grow": "扩大社区参与", - "output.suggest_community_grow_desc": "项目贡献者较少,建议吸引更多开发者参与。", - "output.suggest_community_action1": "在 README 中添加贡献指南", - "output.suggest_community_action2": "创建 Good First Issue 标签引导新贡献者", - "output.suggest_community_action3": "在社区宣传项目", - "output.suggest_issues_encourage": "鼓励社区反馈", - "output.suggest_issues_encourage_desc": "项目没有议题,建议鼓励用户反馈。", - "output.suggest_issues_action1": "创建议题模板", - "output.suggest_issues_action2": "在 README 中说明如何提交议题", - "output.suggest_branches_setup": "建立分支管理策略", - "output.suggest_branches_setup_desc": "项目分支管理不足,建议建立分支策略。", - "output.suggest_branches_action1": "创建 develop 分支用于开发", - "output.suggest_branches_action2": "设置分支保护规则", - "output.suggest_release_create": "创建发布版本", - "output.suggest_release_create_desc": "项目没有发布版本,建议创建第一个版本。", - "output.suggest_release_action1": "创建 v1.0.0 或 v0.1.0 标签", - "output.suggest_release_action2": "编写版本说明", - "output.suggest_ci_setup": "配置 CI/CD", - "output.suggest_ci_setup_desc": "项目未配置 CI/CD,建议添加自动化构建和测试。", - "output.suggest_ci_action1": "配置 GitHub Actions 或 GitLink CI", - "output.suggest_ci_action2": "添加自动化测试", - "output.suggest_ci_action3": "配置代码质量检查", - "output.suggest_ci_improve": "提高 CI 稳定性", - "output.suggest_ci_improve_desc": "构建成功率较低,建议改进 CI 配置。", - "output.suggest_ci_improve_action1": "检查失败的构建日志", - "output.suggest_ci_improve_action2": "修复测试失败的问题" + "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)" } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 605dc02..60f5d1e 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -45,14 +45,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), - newBatchReopenShortcut(), - newBatchLabelShortcut(), - newBatchAssignShortcut(), - newBatchCommentShortcut(), - newBatchExportShortcut(), - newBatchImportShortcut(), newBatchUpdateShortcut(), newBatchDeleteShortcut(), + newExportShortcut(tr), { Name: "list", Description: tr.T("cmd.issue.list.short"), @@ -280,40 +275,55 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.issue.comment.short"), Flags: appendIssueNumberFlags( common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + common.Flag{Name: "parent-id", Usage: "Parent comment ID for a threaded reply"}, + common.Flag{Name: "reply-id", Usage: "Comment ID being replied to"}, + common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, ), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload := map[string]interface{}{ - "notes": body, - } - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) - if err != nil { - return err - } - return ctx.Output(env) - }, + Run: runIssueComment, }, { - Name: "journals", - Description: tr.T("cmd.issue.journals.short"), - Flags: issueJournalFlags(tr), - Run: runIssueJournals, + Name: "comments", + Description: "List issue comments and operation records", + Flags: appendIssueNumberFlags( + common.Flag{Name: "category", Short: "c", Usage: "Filter by all, comment, or operate", Default: "comment"}, + common.Flag{Name: "keyword", Short: "k", Usage: "Search comment content"}, + common.Flag{Name: "sort-by", Usage: "Sort field: created_on or updated_on"}, + common.Flag{Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + ), + Run: runIssueComments, }, { - Name: "activity", - Description: tr.T("cmd.issue.activity.short"), - Flags: issueJournalFlags(tr), - Run: runIssueJournals, + Name: "comment-update", + Description: "Update an issue comment", + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, + ), + Run: runIssueCommentUpdate, + }, + { + Name: "comment-delete", + Description: "Delete an issue comment", + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, + ), + Run: runIssueCommentDelete, + }, + { + Name: "comment-replies", + Description: "List replies under an issue comment", + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Usage: "Parent comment ID", Required: true}, + common.Flag{Name: "keyword", Short: "k", Usage: "Search reply content"}, + common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + ), + Run: runIssueCommentReplies, }, { Name: "assigners", @@ -453,14 +463,6 @@ func appendIssueNumberFlags(flags ...common.Flag) []common.Flag { return append(issueNumberFlags(), flags...) } -func issueJournalFlags(tr *i18n.Translator) []common.Flag { - return appendIssueNumberFlags( - common.Flag{Name: "category", Short: "c", Usage: tr.T("flag.issue.journal_category")}, - common.Flag{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, - common.Flag{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "50"}, - ) -} - func issueNumberArg(ctx *common.RuntimeContext) (string, error) { if number := strings.TrimSpace(ctx.Arg("number")); number != "" { return number, nil @@ -471,7 +473,30 @@ func issueNumberArg(ctx *common.RuntimeContext) (string, error) { return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") } -func runIssueJournals(ctx *common.RuntimeContext) error { +func runIssueComment(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + payload, err := issueCommentPayload(ctx, body, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", issueJournalPath(ctx, number), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueComments(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } @@ -481,15 +506,150 @@ func runIssueJournals(ctx *common.RuntimeContext) error { } q := url.Values{} setIssueQueryIfPresent(q, "category", ctx.Arg("category")) + setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setIssueQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setIssueQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) setIssueQueryIfPresent(q, "page", ctx.Arg("page")) setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) - env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), q) + env, err := ctx.CallAPIWithQuery("GET", issueJournalPath(ctx, number), q) if err != nil { return err } return ctx.Output(env) } +func runIssueCommentUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, commentID, err := issueCommentTarget(ctx) + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + payload, err := issueCommentPayload(ctx, body, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", issueJournalItemPath(ctx, number, commentID), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueCommentDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, commentID, err := issueCommentTarget(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", issueJournalItemPath(ctx, number, commentID), nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueCommentReplies(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, commentID, err := issueCommentTarget(ctx) + if err != nil { + return err + } + q := url.Values{} + setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setIssueQueryIfPresent(q, "page", ctx.Arg("page")) + setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", issueJournalItemPath(ctx, number, commentID)+"/children_journals", q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func issueJournalPath(ctx *common.RuntimeContext, number string) string { + return fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), url.PathEscape(number)) +} + +func issueJournalItemPath(ctx *common.RuntimeContext, number, commentID string) string { + return fmt.Sprintf("%s/%s", issueJournalPath(ctx, number), url.PathEscape(commentID)) +} + +func issueCommentTarget(ctx *common.RuntimeContext) (string, string, error) { + number, err := issueNumberArg(ctx) + if err != nil { + return "", "", err + } + commentID, err := ctx.RequireArg("comment-id") + if err != nil { + return "", "", err + } + if _, err := parseIssueID(commentID, "comment-id"); err != nil { + return "", "", err + } + return number, strings.TrimSpace(commentID), nil +} + +func issueCommentPayload(ctx *common.RuntimeContext, body string, includeThreading bool) (map[string]interface{}, error) { + payload := map[string]interface{}{"notes": body} + if includeThreading { + if parentID := ctx.Arg("parent-id"); parentID != "" { + id, err := parseIssueID(parentID, "parent-id") + if err != nil { + return nil, err + } + payload["parent_id"] = id + } + if replyID := ctx.Arg("reply-id"); replyID != "" { + id, err := parseIssueID(replyID, "reply-id") + if err != nil { + return nil, err + } + payload["reply_id"] = id + } + } + if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { + ids, err := parseIssueIDList(attachmentIDs, "attachment-ids") + if err != nil { + return nil, err + } + payload["attachment_ids"] = ids + } + if receivers := parseIssueStringList(ctx.Arg("receivers")); len(receivers) > 0 { + payload["receivers_login"] = receivers + } + return payload, nil +} + +func setIssueQueryIfPresent(q url.Values, name, value string) { + if strings.TrimSpace(value) != "" { + q.Set(name, strings.TrimSpace(value)) + } +} + +func parseIssueStringList(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + item := strings.TrimSpace(part) + if item == "" || seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + return result +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. @@ -695,9 +855,3 @@ func parseIssueID(value, flagName string) (int, error) { } return id, nil } - -func setIssueQueryIfPresent(q url.Values, key, value string) { - if value := strings.TrimSpace(value); value != "" { - q.Set(key, value) - } -} diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 96c6c0b..70168ea 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -94,6 +96,58 @@ func assertNumberSlice(t *testing.T, got interface{}, want []float64) { } } +func assertStringSliceEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func assertNumberSliceEqual(t *testing.T, got, want []int) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func interfaceSliceToStrings(value interface{}) []string { + items, ok := value.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func interfaceSliceToInts(value interface{}) []int { + items, ok := value.([]interface{}) + if !ok { + return nil + } + out := make([]int, 0, len(items)) + for _, item := range items { + if n, ok := item.(float64); ok { + out = append(out, int(n)) + } + } + return out +} + // --- list --- func TestIssueList(t *testing.T) { @@ -171,6 +225,183 @@ func TestIssueListStateAll(t *testing.T) { } } +// --- export --- + +func TestIssueExportCSVWithFiltersAndPagination(t *testing.T) { + var pages []string + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + query := r.URL.Query() + assertEqual(t, query.Get("category"), "opened") + assertEqual(t, query.Get("keyword"), "release") + assertEqual(t, query.Get("participant_category"), "assignedme") + assertEqual(t, query.Get("author_id"), "10") + assertEqual(t, query.Get("assigner_id"), "11") + assertEqual(t, query.Get("milestone_id"), "12") + assertEqual(t, query.Get("status_id"), "1") + assertEqual(t, query.Get("issue_tag_ids"), "2,3") + assertEqual(t, query.Get("sort_by"), "issues.updated_on") + assertEqual(t, query.Get("sort_direction"), "desc") + assertEqual(t, query.Get("limit"), "2") + pages = append(pages, query.Get("page")) + + switch query.Get("page") { + case "1": + writeJSON(t, w, map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{ + "id": float64(101), + "project_issues_index": float64(1), + "subject": "release blocker", + "status": map[string]interface{}{"id": float64(1), "name": "New"}, + "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, + "author": map[string]interface{}{"login": "alice"}, + "assigners": []interface{}{ + map[string]interface{}{"login": "bob"}, + }, + "tags": []interface{}{ + map[string]interface{}{"name": "bug"}, + }, + "updated_on": "2026-06-01", + }, + map[string]interface{}{ + "id": float64(102), + "project_issues_index": float64(2), + "subject": "release notes", + "status": map[string]interface{}{"id": float64(1), "name": "New"}, + "priority": map[string]interface{}{"id": float64(3), "name": "High"}, + "author": map[string]interface{}{"login": "carol"}, + "updated_on": "2026-06-02", + }, + }, + }) + case "2": + writeJSON(t, w, map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{ + "id": float64(103), + "project_issues_index": float64(3), + "subject": "release checklist", + "status": map[string]interface{}{"id": float64(5), "name": "Closed"}, + "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, + "author": map[string]interface{}{"login": "dave"}, + "updated_on": "2026-06-03", + }, + }, + }) + default: + t.Fatalf("unexpected page %s", query.Get("page")) + } + }) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "issues.csv") + err := runShortcut(t, server, "export", map[string]string{ + "state": "open", + "keyword": "release", + "participant": "assignedme", + "author-id": "10", + "assignee-id": "11", + "milestone-id": "12", + "status-id": "1", + "tag-ids": "2,3", + "sort-by": "issues.updated_on", + "sort-direction": "desc", + "limit": "2", + "fields": "number,title,status,priority,author,assignees,tags,updated_at,url", + "export-format": "csv", + "output": outputPath, + }) + if err != nil { + t.Fatalf("export failed: %v", err) + } + assertEqual(t, strings.Join(pages, ","), "1,2") + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read export file: %v", err) + } + got := string(content) + if !strings.Contains(got, "number,title,status,priority,author,assignees,tags,updated_at,url") { + t.Fatalf("missing csv header: %s", got) + } + if !strings.Contains(got, "1,release blocker,New,Normal,alice,bob,bug,2026-06-01,https://www.gitlink.org.cn/owner/repo/issues/1") { + t.Fatalf("missing first issue row: %s", got) + } + if !strings.Contains(got, "3,release checklist,Closed,Normal,dave,,,2026-06-03,https://www.gitlink.org.cn/owner/repo/issues/3") { + t.Fatalf("missing second page issue row: %s", got) + } +} + +func TestIssueExportMaxStopsWithinPage(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") != "1" { + t.Fatalf("unexpected page: %s", r.URL.Query().Get("page")) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{"project_issues_index": float64(1), "subject": "one"}, + map[string]interface{}{"project_issues_index": float64(2), "subject": "two"}, + }, + }) + }) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "issues.json") + err := runShortcut(t, server, "export", map[string]string{ + "limit": "2", + "max": "1", + "fields": "number,title", + "export-format": "json", + "output": outputPath, + }) + if err != nil { + t.Fatalf("export failed: %v", err) + } + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read export file: %v", err) + } + if strings.Contains(string(content), `"title": "two"`) { + t.Fatalf("expected max=1 to omit second issue: %s", content) + } + if !strings.Contains(string(content), `"title": "one"`) { + t.Fatalf("expected first issue in json export: %s", content) + } +} + +func TestIssueExportMarkdownEscapesCells(t *testing.T) { + content := renderIssueExportMarkdown([]issueExportRecord{ + {"number": "1", "title": "pipe | newline\ntext"}, + }, []string{"number", "title"}) + got := string(content) + if !strings.Contains(got, "pipe \\| newline text") { + t.Fatalf("markdown cell not escaped: %s", got) + } +} + +func TestIssueExportRejectsInvalidOptions(t *testing.T) { + if _, err := parseIssueExportFields("number,unknown"); err == nil { + t.Fatal("expected invalid field error") + } + if _, err := normalizeIssueExportFormat("xml"); err == nil { + t.Fatal("expected invalid format error") + } + if _, err := boundedPositiveInt("0", 50, 100, "limit"); err == nil { + t.Fatal("expected invalid limit error") + } + if _, err := nonNegativeInt("-1", "max"); err == nil { + t.Fatal("expected invalid max error") + } +} + // --- create --- func TestIssueCreate(t *testing.T) { @@ -717,6 +948,136 @@ func TestIssueCommentAcceptsIDAlias(t *testing.T) { assertEqual(t, commentPayload["notes"], "Fixed") } +func TestIssueCommentSupportsThreadingAttachmentsAndReceivers(t *testing.T) { + var commentPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + commentPayload = decodeJSON(t, r) + writeJSON(t, w, commentPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "comment", map[string]string{ + "number": "42", + "body": "Reply with context", + "parent-id": "10", + "reply-id": "11", + "attachment-ids": "5, 6", + "receivers": "alice, bob, alice", + }) + if err != nil { + t.Fatalf("comment shortcut failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Reply with context") + assertEqual(t, commentPayload["parent_id"], float64(10)) + assertEqual(t, commentPayload["reply_id"], float64(11)) + assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice", "bob"}) + assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{5, 6}) +} + +func TestIssueCommentsListSendsFilters(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + assertEqual(t, q.Get("category"), "all") + assertEqual(t, q.Get("keyword"), "panic") + assertEqual(t, q.Get("sort_by"), "updated_on") + assertEqual(t, q.Get("sort_direction"), "desc") + assertEqual(t, q.Get("page"), "2") + assertEqual(t, q.Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "journals": []interface{}{ + map[string]interface{}{"id": float64(7), "notes": "panic fixed"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "comments", map[string]string{ + "number": "42", + "category": "all", + "keyword": "panic", + "sort-by": "updated_on", + "sort-direction": "desc", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("comments shortcut failed: %v", err) + } +} + +func TestIssueCommentUpdate(t *testing.T) { + var commentPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + commentPayload = decodeJSON(t, r) + writeJSON(t, w, commentPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-update", map[string]string{ + "number": "42", + "comment-id": "9", + "body": "Updated", + "attachment-ids": "8", + "receivers": "alice", + }) + if err != nil { + t.Fatalf("comment-update failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Updated") + assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{8}) + assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice"}) +} + +func TestIssueCommentDelete(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"}) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42", "comment-id": "9"}) + if err != nil { + t.Fatalf("comment-delete failed: %v", err) + } +} + +func TestIssueCommentReplies(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9/children_journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + assertEqual(t, q.Get("keyword"), "thanks") + assertEqual(t, q.Get("page"), "3") + assertEqual(t, q.Get("limit"), "10") + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-replies", map[string]string{ + "number": "42", + "comment-id": "9", + "keyword": "thanks", + "page": "3", + "limit": "10", + }) + if err != nil { + t.Fatalf("comment-replies failed: %v", err) + } +} + func TestIssueCommentMissingBody(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") @@ -729,74 +1090,6 @@ func TestIssueCommentMissingBody(t *testing.T) { } } -// --- journals / activity --- - -func TestIssueJournals(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Fatalf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - assertEqual(t, r.URL.Query().Get("category"), "comment") - assertEqual(t, r.URL.Query().Get("page"), "2") - assertEqual(t, r.URL.Query().Get("limit"), "50") - writeJSON(t, w, map[string]interface{}{ - "journals": []interface{}{ - map[string]interface{}{"id": float64(1), "notes": "hello"}, - }, - }) - }) - defer server.Close() - - err := runShortcut(t, server, "journals", map[string]string{ - "number": "42", - "category": "comment", - "page": "2", - "limit": "50", - }) - if err != nil { - t.Fatalf("journals failed: %v", err) - } -} - -func TestIssueActivityUsesSameJournalEndpoint(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - if got := r.URL.Query().Get("category"); got != "" { - t.Fatalf("category should be omitted when not passed, got %q", got) - } - assertEqual(t, r.URL.Query().Get("page"), "1") - assertEqual(t, r.URL.Query().Get("limit"), "20") - writeJSON(t, w, map[string]interface{}{"journals": []interface{}{}}) - }) - defer server.Close() - - err := runShortcut(t, server, "activity", map[string]string{ - "id": "42", - "page": "1", - "limit": "20", - }) - if err != nil { - t.Fatalf("activity failed: %v", err) - } -} - -func TestIssueJournalsMissingNumber(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - }) - defer server.Close() - - err := runShortcut(t, server, "journals", map[string]string{"category": "comment"}) - if err == nil { - t.Fatal("expected error for missing number") - } -} - func TestIssueNumberOrIDIsRequired(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) @@ -811,8 +1104,10 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { {name: "close", args: map[string]string{}}, {name: "update", args: map[string]string{"title": "New title"}}, {name: "comment", args: map[string]string{"body": "Fixed"}}, - {name: "journals", args: map[string]string{}}, - {name: "activity", args: map[string]string{}}, + {name: "comments", args: map[string]string{}}, + {name: "comment-update", args: map[string]string{"comment-id": "9", "body": "Updated"}}, + {name: "comment-delete", args: map[string]string{"comment-id": "9"}}, + {name: "comment-replies", args: map[string]string{"comment-id": "9"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -827,6 +1122,32 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { } } +func TestIssueCommentRejectsInvalidIDs(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid IDs should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + cmd string + args map[string]string + }{ + {name: "bad parent", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "parent-id": "abc"}}, + {name: "bad attachment", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "attachment-ids": "1,,"}}, + {name: "bad update comment", cmd: "comment-update", args: map[string]string{"number": "42", "comment-id": "0", "body": "x"}}, + {name: "bad delete comment", cmd: "comment-delete", args: map[string]string{"number": "42", "comment-id": "-1"}}, + {name: "bad replies comment", cmd: "comment-replies", args: map[string]string{"number": "42", "comment-id": "abc"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, tc.cmd, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + // --- batch-close --- func TestBatchClosePreservesCurrentDescription(t *testing.T) { @@ -1108,18 +1429,6 @@ func TestIssueUpdateHTTPError(t *testing.T) { } } -func TestIssueJournalsHTTPError(t *testing.T) { - server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { - writeText(t, w, http.StatusInternalServerError, "server error") - }) - defer server.Close() - - err := runShortcut(t, server, "journals", map[string]string{"number": "42"}) - if err == nil { - t.Fatal("expected error for HTTP 500") - } -} - func TestIssueCloseHTTPError(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { diff --git a/skills/README.md b/skills/README.md index b5e736d..908d763 100644 --- a/skills/README.md +++ b/skills/README.md @@ -130,7 +130,7 @@ skills/ |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | -| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete`, `issue +export` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 9f0259b..c96d352 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -28,6 +28,7 @@ metadata: | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) | | `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) | +| `issue +export` | 按筛选条件批量导出 Issue 到 CSV/JSON/Markdown | 否(公开项目) | | `issue +comment` | 添加评论 | 是 | | `issue +journals` | 查询 Issue 动态记录,支持分类和分页 | 否(公开项目) | | `issue +activity` | 查询 Issue 活动事件,复用 journals 端点 | 否(公开项目) | @@ -71,6 +72,9 @@ gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --sta gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes +# 导出打开的 Issue 到 CSV,用于周报、迁移或离线分析 +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword 登录 --export-format csv --output issues.csv + # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证"