Merge PR #271: feat(issue): 补齐 Issue 批量导出与评论管理工作流
# Conflicts: # README.zh-CN.md # internal/i18n/locales/en-US.json # internal/i18n/locales/zh-CN.json # shortcuts/issue/issue.go # shortcuts/issue/issue_test.go
This commit is contained in:
commit
80d7e344c3
16
README.md
16
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
|
||||
|
||||
|
|
|
|||
111
README.zh-CN.md
111
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 <version_id> -b
|
|||
gitlink-cli release +delete --owner Gitlink --repo forgeplus -i <version_id> --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 <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) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 已保存(用户信息不可用)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 "已修复,请验证"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue