From 7f731eb04f5454b644b03b471774fb38fd536224 Mon Sep 17 00:00:00 2001 From: luwanzhou Date: Sun, 14 Jun 2026 11:48:22 +0800 Subject: [PATCH 1/2] feat(dataset): add dataset query shortcuts Add a `dataset` shortcut group for querying GitLink research datasets, which previously had no shortcut coverage: - dataset +list --ids -> GET /v1/project_datasets - dataset +view -> GET /v1/project_datasets (project ID resolved from --owner/--repo) Only the platform-wide query endpoint is available on production gitlink.org.cn; the documented per-repo dataset CRUD routes return 404 there (verified against the API), so this group wraps the query endpoint for both listing and per-repo viewing. Includes unit tests, bilingual (en-US/zh-CN) i18n help text, README updates, and a change note. Co-Authored-By: Claude Opus 4.8 --- README.md | 17 ++++ README.zh-CN.md | 15 +++ doc/changes/dataset-shortcuts.md | 55 +++++++++++ internal/i18n/locales/en-US.json | 23 +++-- internal/i18n/locales/zh-CN.json | 23 +++-- shortcuts/dataset/dataset.go | 152 ++++++++++++++++++++++++++++++ shortcuts/dataset/dataset_test.go | 150 +++++++++++++++++++++++++++++ shortcuts/register.go | 3 + shortcuts/register_test.go | 2 +- 9 files changed, 423 insertions(+), 17 deletions(-) create mode 100644 doc/changes/dataset-shortcuts.md create mode 100644 shortcuts/dataset/dataset.go create mode 100644 shortcuts/dataset/dataset_test.go diff --git a/README.md b/README.md index b43dcd1..0c3253a 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs | | 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔍 Search | Search repositories, users | +| 📊 Dataset | Query research datasets by project | | 👤 User | View user profiles and info | | 📋 PM | Sprint management, kanban boards, weekly reports | | 🤖 Workflow | AI-powered issue triage, PR review, release notes | @@ -576,6 +577,22 @@ Safety: - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +### Dataset + +`dataset` queries GitLink research datasets (title, description, paper content, +license, owning project). + +```bash +# List datasets for one or more projects (by numeric project ID) +gitlink-cli dataset +list --ids 5988 + +# View a repository's dataset (project ID resolved from --owner/--repo) +gitlink-cli dataset +view --owner Gitlink --repo forgeplus +``` + +> Note: only the platform-wide dataset query endpoint is available on production +> gitlink.org.cn; the per-repo dataset CRUD routes are not yet deployed there. + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: diff --git a/README.zh-CN.md b/README.zh-CN.md index 265ac98..68549b3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -106,6 +106,7 @@ | 🔧 CI | 查看构建、日志、CI/CD 操作 | | ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 | | 🔍 搜索 | 搜索仓库、用户 | +| 📊 数据集 | 按项目查询科研数据集 | | 👤 用户 | 查看用户资料和信息 | | 📋 项目管理 | Sprint 管理、看板、周报 | | 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | @@ -455,6 +456,20 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### 数据集 + +`dataset` 查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。 + +```bash +# 按数字项目 ID 列出一个或多个项目的数据集 +gitlink-cli dataset +list --ids 5988 + +# 查看仓库的数据集(project_id 从 --owner/--repo 解析) +gitlink-cli dataset +view --owner Gitlink --repo forgeplus +``` + +> 注意:生产环境 gitlink.org.cn 仅提供平台级数据集查询端点;按仓库的数据集增删改查路由尚未在生产部署。 + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: diff --git a/doc/changes/dataset-shortcuts.md b/doc/changes/dataset-shortcuts.md new file mode 100644 index 0000000..90981b3 --- /dev/null +++ b/doc/changes/dataset-shortcuts.md @@ -0,0 +1,55 @@ +# Dataset Shortcuts + +## Summary + +Adds a new `dataset` shortcut group for querying GitLink research datasets, +which previously had no shortcut coverage. Datasets carry research-oriented +metadata (title, description, `paper_content`, license, owning project) that is +valuable for research/scientometric scenarios. + +## Commands + +| Command | Purpose | Endpoint | +|---------|---------|----------| +| `gitlink-cli dataset +list --ids ` | List datasets for one or more projects | `GET /v1/project_datasets` | +| `gitlink-cli dataset +view` | View a repository's dataset | `GET /v1/project_datasets` (project ID resolved from `--owner/--repo`) | + +## Behaviour + +- `dataset +list --ids 1,2,3` queries datasets by comma-separated numeric + project IDs. IDs are validated client-side before the request. +- `dataset +view --owner X --repo Y` resolves the repository's numeric project + ID from the repository info endpoint, then queries the dataset for that + project. Pass `--project-id` to skip resolution. + +## Scope note (verified against production) + +The dataset CRUD routes documented under `/api/v1/{owner}/{repo}/dataset` +(`POST`/`PUT`/`GET`) are **not deployed on the production `gitlink.org.cn` +host** — they return `404 您访问的页面不存在` even for a repository's owner. Only +the platform-wide query endpoint `GET /api/v1/project_datasets` is available in +production, so this group wraps that endpoint for both listing and per-repo +viewing. Create/update/attachment-delete can be added once the corresponding +routes are live on production. + +## Tests + +Unit tests cover `--ids` normalization (whitespace, ordering) and validation, +the missing/invalid `--ids` guards, project ID auto-resolution from repo info +for `+view`, explicit/invalid `--project-id`, and HTTP error handling. + +## 中文说明 + +### 变更内容 + +- 新增 `dataset` 命令组: + - `dataset +list --ids 1,2,3` 按数字项目 ID 查询数据集 + - `dataset +view --owner X --repo Y` 自动解析仓库 project_id 后查询该仓库数据集(可用 `--project-id` 跳过解析) +- 数据集含 `paper_content`、license、所属项目等科研相关元数据,服务科研数据发现场景。 + +### 范围说明(已对生产环境验证) + +文档中 `/api/v1/{owner}/{repo}/dataset` 的增删改查路由在生产 `gitlink.org.cn` +**未部署**(即使对仓库 owner 也返回 `404 页面不存在`)。生产可用的只有平台级查询 +端点 `GET /api/v1/project_datasets`,故本命令组基于该端点实现列表与按仓库查看。待 +对应路由在生产上线后,可补充创建/更新/附件删除。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8df7cf..59389c2 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -21,6 +21,11 @@ "cmd.config.list.short": "List all configuration values", "cmd.config.set.short": "Set a configuration value", "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.dataset.list.long": "List datasets for one or more GitLink projects by their numeric project IDs.", + "cmd.dataset.list.short": "List datasets by project IDs", + "cmd.dataset.short": "Dataset operations", + "cmd.dataset.view.long": "View the dataset of a repository. The project ID is resolved from --owner/--repo, or pass --project-id.", + "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.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", @@ -90,13 +95,13 @@ "error.config.save_failed": "failed to save config: {message}", "error.missing_required_flag": "required flag --{name} is missing", "error.unsupported_language": "unsupported language: {lang}", - "flag.api.body": "Request body (JSON string)", - "flag.api.body_file": "Read request body JSON from a file", - "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", "flag.api.batch_dry_run": "Preview batch requests without sending remote requests", "flag.api.batch_file": "Read an API batch plan from a JSON file", "flag.api.batch_var": "Override a batch template variable (key=value, repeatable)", + "flag.api.body": "Request body (JSON string)", + "flag.api.body_file": "Read request body JSON from a file", + "flag.api.body_stdin": "Read request body JSON from stdin", "flag.api.header": "Additional headers (key:value)", "flag.api.query": "Query parameters (key=val&key2=val2)", "flag.auth.token": "Login by pasting an existing token", @@ -106,6 +111,8 @@ "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", "flag.comment.body": "Comment body", + "flag.dataset.ids": "Comma-separated project IDs to query datasets for", + "flag.dataset.project_id": "GitLink project ID. If omitted, it is resolved from --owner/--repo.", "flag.debug": "Enable debug output", "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", @@ -113,6 +120,8 @@ "flag.format": "Output format: json, table, yaml (default: table)", "flag.issue.add_label": "Label to add to each matching issue", "flag.issue.assignee": "Assignee login", + "flag.issue.assignee_id": "Assignee user ID", + "flag.issue.author_id": "Author user ID", "flag.issue.batch.reason": "Optional reason shown in the batch result", "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", @@ -120,8 +129,6 @@ "flag.issue.batch_label.state": "Filter by issue state", "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.assignee_id": "Assignee user ID", - "flag.issue.author_id": "Author user ID", "flag.issue.body": "Issue description", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", @@ -149,14 +156,14 @@ "flag.pr.file": "Filter diff by file path", "flag.pr.head": "Source branch", "flag.pr.id": "PR number", - "flag.pr.milestone_id": "Milestone ID", "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.milestone_id": "Milestone ID", "flag.pr.priority_id": "Priority ID", "flag.pr.review_commit": "Commit SHA to attach the review to", "flag.pr.review_content": "Review content", - "flag.pr.reviewer_id": "Reviewer user ID", "flag.pr.review_status": "Review status: common, approved, rejected", "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.reviewer_id": "Reviewer user ID", "flag.pr.state": "Filter: open, merged, closed", "flag.pr.tag_id": "Issue tag ID", "flag.pr.title": "PR title", @@ -194,7 +201,6 @@ "output.auth.login_hint": " Run: gitlink-cli auth login", "output.config.file": "Config file: {path}", "output.config.not_set": "(not set)", - "output.version": "gitlink-cli {version}", "output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.", "output.doctor.api_auth.failed": "Authenticated API request failed: {message}", "output.doctor.api_auth.no_login": "Authenticated API response did not include a login field.", @@ -217,6 +223,7 @@ "output.doctor.suggestion.check_token": "Check whether the stored token is valid, or run gitlink-cli auth login again.", "output.doctor.suggestion.fix_config_yaml": "Fix the YAML syntax in the gitlink-cli config file.", "output.doctor.suggestion.pass_owner_repo": "Run the command with --owner and --repo when not inside a GitLink repository.", + "output.version": "gitlink-cli {version}", "prompt.auth.password": "Password: ", "prompt.auth.token": "Paste your access token: ", "prompt.auth.username": "Username/Email/Phone: ", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 7fdc2b1..77fe2dc 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -21,6 +21,11 @@ "cmd.config.list.short": "列出所有配置项", "cmd.config.set.short": "设置配置项", "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.dataset.list.long": "按数字项目 ID 列出一个或多个 GitLink 项目的数据集。", + "cmd.dataset.list.short": "按项目 ID 列出数据集", + "cmd.dataset.short": "数据集操作", + "cmd.dataset.view.long": "查看仓库的数据集。项目 ID 从 --owner/--repo 解析,也可用 --project-id 指定。", + "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", "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", @@ -90,13 +95,13 @@ "error.config.save_failed": "保存配置失败:{message}", "error.missing_required_flag": "缺少必需参数 --{name}", "error.unsupported_language": "不支持的语言:{lang}", - "flag.api.body": "请求体(JSON 字符串)", - "flag.api.body_file": "从文件读取 JSON 请求体", - "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", "flag.api.batch_dry_run": "预览批处理请求,不发送远端请求", "flag.api.batch_file": "从 JSON 文件读取 API 批处理计划", "flag.api.batch_var": "覆盖批处理模板变量(key=value,可重复)", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.body_file": "从文件读取 JSON 请求体", + "flag.api.body_stdin": "从标准输入读取 JSON 请求体", "flag.api.header": "附加请求头(key:value)", "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", @@ -106,6 +111,8 @@ "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", "flag.comment.body": "评论内容", + "flag.dataset.ids": "用于查询数据集的项目 ID,逗号分隔", + "flag.dataset.project_id": "GitLink 项目 ID。省略时从 --owner/--repo 解析。", "flag.debug": "启用调试输出", "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", @@ -113,6 +120,8 @@ "flag.format": "输出格式:json、table、yaml(默认:table)", "flag.issue.add_label": "要添加到每个匹配议题的标签", "flag.issue.assignee": "负责人登录名", + "flag.issue.assignee_id": "负责人用户 ID", + "flag.issue.author_id": "作者用户 ID", "flag.issue.batch.reason": "批量结果中显示的可选原因", "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", @@ -120,8 +129,6 @@ "flag.issue.batch_label.state": "按议题状态筛选", "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", - "flag.issue.assignee_id": "负责人用户 ID", - "flag.issue.author_id": "作者用户 ID", "flag.issue.body": "议题描述", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", @@ -149,14 +156,14 @@ "flag.pr.file": "按文件路径筛选 diff", "flag.pr.head": "源分支", "flag.pr.id": "PR 编号", - "flag.pr.milestone_id": "里程碑 ID", "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.milestone_id": "里程碑 ID", "flag.pr.priority_id": "优先级 ID", "flag.pr.review_commit": "关联评审的 Commit SHA", "flag.pr.review_content": "评审内容", - "flag.pr.reviewer_id": "评审人用户 ID", "flag.pr.review_status": "评审状态:common、approved、rejected", "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.reviewer_id": "评审人用户 ID", "flag.pr.state": "筛选:open、merged、closed", "flag.pr.tag_id": "议题标签 ID", "flag.pr.title": "PR 标题", @@ -194,7 +201,6 @@ "output.auth.login_hint": " 运行:gitlink-cli auth login", "output.config.file": "配置文件:{path}", "output.config.not_set": "(未设置)", - "output.version": "gitlink-cli {version}", "output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。", "output.doctor.api_auth.failed": "认证 API 请求失败:{message}", "output.doctor.api_auth.no_login": "认证 API 响应中缺少 login 字段。", @@ -217,6 +223,7 @@ "output.doctor.suggestion.check_token": "检查已保存的 Token 是否有效,或重新运行 gitlink-cli auth login。", "output.doctor.suggestion.fix_config_yaml": "修复 gitlink-cli 配置文件中的 YAML 语法。", "output.doctor.suggestion.pass_owner_repo": "不在 GitLink 仓库目录内时,请通过 --owner 和 --repo 指定仓库。", + "output.version": "gitlink-cli {version}", "prompt.auth.password": "密码:", "prompt.auth.token": "粘贴你的访问 Token:", "prompt.auth.username": "用户名/邮箱/手机号:", diff --git a/shortcuts/dataset/dataset.go b/shortcuts/dataset/dataset.go new file mode 100644 index 0000000..cbf0a5f --- /dev/null +++ b/shortcuts/dataset/dataset.go @@ -0,0 +1,152 @@ +// Package dataset implements shortcuts for querying GitLink research datasets. +// +// GitLink exposes dataset metadata (title, description, paper_content, license, +// owning project) through the platform-wide query endpoint +// GET /api/v1/project_datasets. The per-repository dataset CRUD routes +// documented under /api/v1/{owner}/{repo}/dataset are not deployed on the +// production gitlink.org.cn host, so this package wraps the query endpoint and +// resolves a repository's project ID to offer both a list and a per-repo view. +package dataset + +import ( + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns dataset query shortcuts. +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) + + return []*common.Shortcut{ + { + Name: "list", + Description: tr.T("cmd.dataset.list.short"), + Long: tr.T("cmd.dataset.list.long"), + Flags: []common.Flag{ + {Name: "ids", Usage: tr.T("flag.dataset.ids"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + ids, err := ctx.RequireArg("ids") + if err != nil { + return err + } + normalized, err := normalizeIDs(ids) + if err != nil { + return err + } + return queryDatasets(ctx, normalized) + }, + }, + { + Name: "view", + Description: tr.T("cmd.dataset.view.short"), + Long: tr.T("cmd.dataset.view.long"), + Flags: []common.Flag{ + {Name: "project-id", Usage: tr.T("flag.dataset.project_id")}, + }, + Run: func(ctx *common.RuntimeContext) error { + projectID := strings.TrimSpace(ctx.Arg("project-id")) + if projectID == "" { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + resolved, err := resolveProjectID(ctx) + if err != nil { + return err + } + projectID = strconv.FormatInt(resolved, 10) + } else if _, err := strconv.ParseInt(projectID, 10, 64); err != nil { + return fmt.Errorf("invalid --project-id %q: use a numeric project ID", projectID) + } + return queryDatasets(ctx, projectID) + }, + }, + } +} + +// queryDatasets calls the platform dataset query endpoint with a comma-separated +// list of project IDs. +func queryDatasets(ctx *common.RuntimeContext, ids string) error { + q := url.Values{} + q.Set("ids", ids) + env, err := ctx.CallAPIWithQuery("GET", "/v1/project_datasets", q) + if err != nil { + return err + } + return ctx.Output(env) +} + +// normalizeIDs validates a comma-separated list of positive integer project IDs +// and returns it without surrounding whitespace. +func normalizeIDs(raw string) (string, error) { + parts := strings.Split(raw, ",") + cleaned := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil || n <= 0 { + return "", fmt.Errorf("invalid --ids %q: use comma-separated positive project IDs", raw) + } + cleaned = append(cleaned, strconv.FormatInt(n, 10)) + } + if len(cleaned) == 0 { + return "", fmt.Errorf("invalid --ids %q: provide at least one project ID", raw) + } + return strings.Join(cleaned, ","), nil +} + +// resolveProjectID resolves the numeric GitLink project ID from the current +// owner/repo via the repository info endpoint. +func resolveProjectID(ctx *common.RuntimeContext) (int64, error) { + env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) + if err != nil { + return 0, fmt.Errorf("resolve project id: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("resolve project id: unexpected repository response") + } + for _, key := range []string{"id", "project_id"} { + if id, ok := projectIDValue(data[key]); ok { + return id, nil + } + } + return 0, fmt.Errorf("resolve project id: repository response did not include id") +} + +func projectIDValue(value interface{}) (int64, bool) { + switch v := value.(type) { + case float64: + if v > 0 { + return int64(v), true + } + case int: + if v > 0 { + return int64(v), true + } + case int64: + if v > 0 { + return v, true + } + case string: + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { + return n, true + } + } + return 0, false +} + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/dataset/dataset_test.go b/shortcuts/dataset/dataset_test.go new file mode 100644 index 0000000..0aecaca --- /dev/null +++ b/shortcuts/dataset/dataset_test.go @@ -0,0 +1,150 @@ +package dataset + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "alice", + Repo: "demo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// --- list --- + +func TestDatasetListNormalizesIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/project_datasets.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("ids"); got != "1,2,3" { + t.Fatalf("ids = %q, want 1,2,3", got) + } + writeJSON(w, map[string]interface{}{"total_count": float64(0), "project_datasets": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{"ids": " 1, 2 ,3 "}); err != nil { + t.Fatalf("list failed: %v", err) + } +} + +func TestDatasetListMissingIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected when ids missing") + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{}); err == nil { + t.Fatal("expected error for missing --ids") + } +} + +func TestDatasetListInvalidIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected for invalid ids") + })) + defer server.Close() + + if err := runShortcut(t, server, "list", map[string]string{"ids": "abc"}); err == nil { + t.Fatal("expected error for non-numeric --ids") + } +} + +// --- view --- + +func TestDatasetViewResolvesProjectID(t *testing.T) { + var sawRepo, sawQuery bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/alice/demo.json": + sawRepo = true + writeJSON(w, map[string]interface{}{"id": float64(5988)}) + case "/v1/project_datasets.json": + sawQuery = true + if got := r.URL.Query().Get("ids"); got != "5988" { + t.Fatalf("ids = %q, want 5988", got) + } + writeJSON(w, map[string]interface{}{"total_count": float64(1), "project_datasets": []interface{}{}}) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + if err := runShortcut(t, server, "view", map[string]string{}); err != nil { + t.Fatalf("view failed: %v", err) + } + if !sawRepo || !sawQuery { + t.Fatalf("expected repo+query calls, got repo=%v query=%v", sawRepo, sawQuery) + } +} + +func TestDatasetViewExplicitProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/project_datasets.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("ids"); got != "42" { + t.Fatalf("ids = %q, want 42", got) + } + writeJSON(w, map[string]interface{}{"total_count": float64(0), "project_datasets": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "view", map[string]string{"project-id": "42"}); err != nil { + t.Fatalf("view failed: %v", err) + } +} + +func TestDatasetViewInvalidProjectID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected for invalid project id") + })) + defer server.Close() + + if err := runShortcut(t, server, "view", map[string]string{"project-id": "x"}); err == nil { + t.Fatal("expected error for invalid --project-id") + } +} + +func TestDatasetViewHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("server error")) + })) + defer server.Close() + + if err := runShortcut(t, server, "view", map[string]string{"project-id": "42"}); err == nil { + t.Fatal("expected error for HTTP 500") + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 917bf85..613f39f 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -8,6 +8,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" + "github.com/gitlink-org/gitlink-cli/shortcuts/dataset" "github.com/gitlink-org/gitlink-cli/shortcuts/health" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" @@ -47,6 +48,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": search.Shortcuts(tr), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), + "dataset": dataset.Shortcuts(tr), "webhook": webhook.Shortcuts(tr), "health": health.Shortcuts(tr), "workflow": workflow.Shortcuts(), @@ -68,6 +70,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": tr.T("cmd.search.short"), "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", + "dataset": tr.T("cmd.dataset.short"), "webhook": tr.T("cmd.webhook.short"), "health": "Project health data collection", "workflow": "AI agent workflow analysis", diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a8c8ce4..80fd8e8 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) { "repo", "issue", "label", "license", "pr", "release", "branch", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", - "health", + "dataset", "health", } groupSet := map[string]bool{} From 29ca94db76f9fbf9e9abca183d1e837b5c098828 Mon Sep 17 00:00:00 2001 From: luwanzhou Date: Sun, 14 Jun 2026 14:01:57 +0800 Subject: [PATCH 2/2] feat(dataset): add full dataset CRUD (view/create/update/delete-attachment) Extend the dataset group beyond the verified project query to the full documented contract: - dataset +view -> GET /v1/{owner}/{repo}/dataset (+page/limit) - dataset +list --ids -> GET /v1/project_datasets (prod-verified) - dataset +create -> POST /v1/{owner}/{repo}/dataset - dataset +update -> PUT /v1/{owner}/{repo}/dataset - dataset +delete-attachment -> DELETE /attachments/{uuid} Quality over a bare contract port: bilingual (en-US/zh-CN) i18n help, --dry-run preview on create/update, --yes confirmation guard on the destructive attachment delete, and license-id validation. Only /v1/project_datasets is currently reachable on production www; the per-repo routes follow the published OpenAPI spec and return 404 there until the platform deploys them (documented in the change note). Unit tests exercise every command against a mock server. Co-Authored-By: Claude Opus 4.8 --- README.md | 20 ++- README.zh-CN.md | 14 +- doc/changes/dataset-shortcuts.md | 75 ++++++----- internal/i18n/locales/en-US.json | 20 ++- internal/i18n/locales/zh-CN.json | 20 ++- shortcuts/dataset/dataset.go | 216 +++++++++++++++++++----------- shortcuts/dataset/dataset_test.go | 170 ++++++++++++++++------- 7 files changed, 372 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 0c3253a..ef57f06 100644 --- a/README.md +++ b/README.md @@ -579,19 +579,29 @@ Safety: ### Dataset -`dataset` queries GitLink research datasets (title, description, paper content, -license, owning project). +`dataset` manages and queries GitLink research datasets (title, description, +paper content, license, owning project). ```bash # List datasets for one or more projects (by numeric project ID) gitlink-cli dataset +list --ids 5988 -# View a repository's dataset (project ID resolved from --owner/--repo) +# View a repository's dataset and attachments gitlink-cli dataset +view --owner Gitlink --repo forgeplus + +# Create / update a repository's dataset (preview first with --dry-run) +gitlink-cli dataset +create --owner me --repo proj -t "My dataset" -d "..." --license-id 359 --dry-run +gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated" + +# Delete a dataset attachment (destructive: preview, then confirm with --yes) +gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --dry-run +gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --yes ``` -> Note: only the platform-wide dataset query endpoint is available on production -> gitlink.org.cn; the per-repo dataset CRUD routes are not yet deployed there. +> Note: `dataset +list` (platform dataset query) is verified on production +> gitlink.org.cn. The per-repo `+view`/`+create`/`+update` routes follow the +> published OpenAPI contract but are not yet deployed on production (they return +> 404 there); they will work once the platform enables them. ### Raw API diff --git a/README.zh-CN.md b/README.zh-CN.md index 68549b3..396f463 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -458,17 +458,25 @@ gitlink-cli search +users -k "zhangsan" ### 数据集 -`dataset` 查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。 +`dataset` 管理并查询 GitLink 科研数据集(标题、描述、论文内容、许可证、所属项目)。 ```bash # 按数字项目 ID 列出一个或多个项目的数据集 gitlink-cli dataset +list --ids 5988 -# 查看仓库的数据集(project_id 从 --owner/--repo 解析) +# 查看仓库的数据集及其附件 gitlink-cli dataset +view --owner Gitlink --repo forgeplus + +# 创建 / 更新仓库数据集(先用 --dry-run 预览) +gitlink-cli dataset +create --owner me --repo proj -t "我的数据集" -d "..." --license-id 359 --dry-run +gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新" + +# 删除数据集附件(破坏性:先预览,再用 --yes 确认) +gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --dry-run +gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --yes ``` -> 注意:生产环境 gitlink.org.cn 仅提供平台级数据集查询端点;按仓库的数据集增删改查路由尚未在生产部署。 +> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。 ### Raw API diff --git a/doc/changes/dataset-shortcuts.md b/doc/changes/dataset-shortcuts.md index 90981b3..4bf9bb0 100644 --- a/doc/changes/dataset-shortcuts.md +++ b/doc/changes/dataset-shortcuts.md @@ -2,54 +2,69 @@ ## Summary -Adds a new `dataset` shortcut group for querying GitLink research datasets, -which previously had no shortcut coverage. Datasets carry research-oriented -metadata (title, description, `paper_content`, license, owning project) that is -valuable for research/scientometric scenarios. +Adds a new `dataset` shortcut group for managing and querying GitLink research +datasets, which previously had no shortcut coverage. Datasets carry +research-oriented metadata (title, description, `paper_content`, license, owning +project) that is valuable for research/scientometric scenarios. ## Commands | Command | Purpose | Endpoint | |---------|---------|----------| +| `gitlink-cli dataset +view` | View a repository's dataset and attachments | `GET /v1/{owner}/{repo}/dataset` | | `gitlink-cli dataset +list --ids ` | List datasets for one or more projects | `GET /v1/project_datasets` | -| `gitlink-cli dataset +view` | View a repository's dataset | `GET /v1/project_datasets` (project ID resolved from `--owner/--repo`) | +| `gitlink-cli dataset +create` | Create a repository's dataset | `POST /v1/{owner}/{repo}/dataset` | +| `gitlink-cli dataset +update` | Update a repository's dataset | `PUT /v1/{owner}/{repo}/dataset` | +| `gitlink-cli dataset +delete-attachment --uuid ` | Delete a dataset attachment | `DELETE /attachments/{uuid}` | ## Behaviour -- `dataset +list --ids 1,2,3` queries datasets by comma-separated numeric - project IDs. IDs are validated client-side before the request. -- `dataset +view --owner X --repo Y` resolves the repository's numeric project - ID from the repository info endpoint, then queries the dataset for that - project. Pass `--project-id` to skip resolution. +- `+view` paginates attachments via `--page`/`--limit`. +- `+list --ids 1,2,3` queries datasets by comma-separated numeric project IDs; + IDs are validated client-side before the request. +- `+create`/`+update` send `title`, `description`, optional `license-id` + (validated as a positive integer) and `paper-content`. Both support + `--dry-run` to preview the request body without writing. +- `+delete-attachment` is destructive: it requires `--dry-run` preview or an + explicit `--yes` confirmation before issuing the DELETE. -## Scope note (verified against production) +## Production status (verified) -The dataset CRUD routes documented under `/api/v1/{owner}/{repo}/dataset` -(`POST`/`PUT`/`GET`) are **not deployed on the production `gitlink.org.cn` -host** — they return `404 您访问的页面不存在` even for a repository's owner. Only -the platform-wide query endpoint `GET /api/v1/project_datasets` is available in -production, so this group wraps that endpoint for both listing and per-repo -viewing. Create/update/attachment-delete can be added once the corresponding -routes are live on production. +Verified against production `gitlink.org.cn`: + +- `GET /v1/project_datasets` (`+list`) — **available and verified** (e.g. + `--ids 5988` returns the forgeplus dataset). +- The per-repository routes `/v1/{owner}/{repo}/dataset` + (`+view`/`+create`/`+update`) currently return `404` on production www + (confirmed even for a repository's own owner; not reachable on the gateway + host either). They follow the documented contract and are expected to work + once the platform deploys these routes. `+delete-attachment` targets the + generic attachments endpoint. + +The commands and request shapes match the published OpenAPI spec, so they are +ready the moment the routes go live; unit tests exercise every command against a +mock server. ## Tests -Unit tests cover `--ids` normalization (whitespace, ordering) and validation, -the missing/invalid `--ids` guards, project ID auto-resolution from repo info -for `+view`, explicit/invalid `--project-id`, and HTTP error handling. +Unit tests cover the view path with pagination, `--ids` normalization and +validation, create/update request bodies and `license-id` validation, dry-run +previews, and the destructive-delete confirmation guard (`--yes`). ## 中文说明 ### 变更内容 -- 新增 `dataset` 命令组: - - `dataset +list --ids 1,2,3` 按数字项目 ID 查询数据集 - - `dataset +view --owner X --repo Y` 自动解析仓库 project_id 后查询该仓库数据集(可用 `--project-id` 跳过解析) -- 数据集含 `paper_content`、license、所属项目等科研相关元数据,服务科研数据发现场景。 +- 新增 `dataset` 命令组:`+view`、`+list`、`+create`、`+update`、`+delete-attachment`。 +- `+view` 支持 `--page`/`--limit` 对附件分页;`+list --ids` 按项目 ID 查询。 +- `+create`/`+update` 发送 `title`/`description`/可选 `license-id`/`paper-content`,均支持 `--dry-run` 预览。 +- `+delete-attachment` 为破坏性操作,需 `--dry-run` 预览或显式 `--yes` 确认。 -### 范围说明(已对生产环境验证) +### 生产状态(已验证) -文档中 `/api/v1/{owner}/{repo}/dataset` 的增删改查路由在生产 `gitlink.org.cn` -**未部署**(即使对仓库 owner 也返回 `404 页面不存在`)。生产可用的只有平台级查询 -端点 `GET /api/v1/project_datasets`,故本命令组基于该端点实现列表与按仓库查看。待 -对应路由在生产上线后,可补充创建/更新/附件删除。 +- `GET /v1/project_datasets`(`+list`)在生产**可用并已验证**(如 `--ids 5988` 返回 forgeplus 数据集)。 +- `/v1/{owner}/{repo}/dataset` 的 `+view`/`+create`/`+update` 当前在生产 www 返回 `404`(即使对仓库 owner 也如此,gateway 也未托管)。实现严格遵循已发布的 OpenAPI 契约,待平台部署后即可生效;单测以 mock 覆盖全部命令。 + +### 相对文档契约的增强 + +双语 i18n 帮助文案、写操作 `--dry-run` 预览、破坏性删除 `--yes` 二次确认、`license-id` 正整数校验。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 59389c2..9bc2cbc 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -21,10 +21,16 @@ "cmd.config.list.short": "List all configuration values", "cmd.config.set.short": "Set a configuration value", "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.dataset.create.long": "Create the dataset of a repository with a title, description, optional license and research paper content.", + "cmd.dataset.create.short": "Create a repository's dataset", + "cmd.dataset.delete_attachment.long": "Delete a dataset attachment by its UUID. This is destructive: preview with --dry-run, then pass --yes to confirm.", + "cmd.dataset.delete_attachment.short": "Delete a dataset attachment by UUID", "cmd.dataset.list.long": "List datasets for one or more GitLink projects by their numeric project IDs.", "cmd.dataset.list.short": "List datasets by project IDs", "cmd.dataset.short": "Dataset operations", - "cmd.dataset.view.long": "View the dataset of a repository. The project ID is resolved from --owner/--repo, or pass --project-id.", + "cmd.dataset.update.long": "Update the dataset of a repository (title, description, optional license and research paper content).", + "cmd.dataset.update.short": "Update a repository's dataset", + "cmd.dataset.view.long": "View a repository's dataset and its attachments. Use --page/--limit to paginate attachments.", "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", @@ -93,6 +99,7 @@ "error.auth.store_token_failed": "failed to store token: {message}", "error.auth.token_empty": "token cannot be empty", "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.unsupported_language": "unsupported language: {lang}", "flag.api.batch_continue_on_error": "Continue running remaining batch requests after a failure", @@ -111,8 +118,17 @@ "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", "flag.comment.body": "Comment body", + "flag.dataset.description": "Dataset description", + "flag.dataset.dry_run": "Preview the request without writing the dataset", + "flag.dataset.dry_run_delete": "Preview the request without deleting the attachment", "flag.dataset.ids": "Comma-separated project IDs to query datasets for", - "flag.dataset.project_id": "GitLink project ID. If omitted, it is resolved from --owner/--repo.", + "flag.dataset.license_id": "License ID", + "flag.dataset.limit": "Attachment page size", + "flag.dataset.page": "Attachment page number", + "flag.dataset.paper_content": "Research paper content", + "flag.dataset.title": "Dataset title", + "flag.dataset.uuid": "Attachment UUID", + "flag.dataset.yes": "Confirm the destructive deletion", "flag.debug": "Enable debug output", "flag.description": "Description", "flag.doctor.skip_network": "Skip authenticated API connectivity checks", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 77fe2dc..d3df9bd 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -21,10 +21,16 @@ "cmd.config.list.short": "列出所有配置项", "cmd.config.set.short": "设置配置项", "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.dataset.create.long": "为仓库创建数据集,包含标题、描述、可选许可证和研究论文内容。", + "cmd.dataset.create.short": "创建仓库数据集", + "cmd.dataset.delete_attachment.long": "按 UUID 删除数据集附件。该操作具有破坏性:先用 --dry-run 预览,再传 --yes 确认。", + "cmd.dataset.delete_attachment.short": "按 UUID 删除数据集附件", "cmd.dataset.list.long": "按数字项目 ID 列出一个或多个 GitLink 项目的数据集。", "cmd.dataset.list.short": "按项目 ID 列出数据集", "cmd.dataset.short": "数据集操作", - "cmd.dataset.view.long": "查看仓库的数据集。项目 ID 从 --owner/--repo 解析,也可用 --project-id 指定。", + "cmd.dataset.update.long": "更新仓库数据集(标题、描述、可选许可证和研究论文内容)。", + "cmd.dataset.update.short": "更新仓库数据集", + "cmd.dataset.view.long": "查看仓库的数据集及其附件。用 --page/--limit 对附件分页。", "cmd.dataset.view.short": "查看仓库数据集", "cmd.doctor.long": "诊断 gitlink-cli 的配置、认证、仓库上下文和 API 连通性问题。", "cmd.doctor.short": "诊断 gitlink-cli 环境问题", @@ -93,6 +99,7 @@ "error.auth.store_token_failed": "保存 Token 失败:{message}", "error.auth.token_empty": "Token 不能为空", "error.config.save_failed": "保存配置失败:{message}", + "error.dataset.delete_confirm": "删除数据集附件具有破坏性;请先 --dry-run 预览,再传 --yes 确认", "error.missing_required_flag": "缺少必需参数 --{name}", "error.unsupported_language": "不支持的语言:{lang}", "flag.api.batch_continue_on_error": "批处理请求失败后继续执行后续请求", @@ -111,8 +118,17 @@ "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", "flag.comment.body": "评论内容", + "flag.dataset.description": "数据集描述", + "flag.dataset.dry_run": "预览请求,不写入数据集", + "flag.dataset.dry_run_delete": "预览请求,不删除附件", "flag.dataset.ids": "用于查询数据集的项目 ID,逗号分隔", - "flag.dataset.project_id": "GitLink 项目 ID。省略时从 --owner/--repo 解析。", + "flag.dataset.license_id": "许可证 ID", + "flag.dataset.limit": "附件每页数量", + "flag.dataset.page": "附件页码", + "flag.dataset.paper_content": "研究论文内容", + "flag.dataset.title": "数据集标题", + "flag.dataset.uuid": "附件 UUID", + "flag.dataset.yes": "确认执行破坏性删除", "flag.debug": "启用调试输出", "flag.description": "描述", "flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查", diff --git a/shortcuts/dataset/dataset.go b/shortcuts/dataset/dataset.go index cbf0a5f..aedc40c 100644 --- a/shortcuts/dataset/dataset.go +++ b/shortcuts/dataset/dataset.go @@ -1,11 +1,11 @@ -// Package dataset implements shortcuts for querying GitLink research datasets. +// Package dataset implements shortcuts for managing and querying GitLink +// research datasets: per-repository dataset detail/create/update, the +// platform-wide dataset query, and dataset attachment deletion. // -// GitLink exposes dataset metadata (title, description, paper_content, license, -// owning project) through the platform-wide query endpoint -// GET /api/v1/project_datasets. The per-repository dataset CRUD routes -// documented under /api/v1/{owner}/{repo}/dataset are not deployed on the -// production gitlink.org.cn host, so this package wraps the query endpoint and -// resolves a repository's project ID to offer both a list and a per-repo view. +// Datasets carry research-oriented metadata (title, description, paper_content, +// license, owning project) that is valuable for research/scientometric +// scenarios. Per-repository CRUD wraps /api/v1/{owner}/{repo}/dataset; the +// platform query wraps /api/v1/project_datasets. package dataset import ( @@ -18,11 +18,41 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -// Shortcuts returns dataset query shortcuts. +// Shortcuts returns dataset management and query shortcuts. func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { tr := shortcutTranslator(translators...) + writeFlags := []common.Flag{ + {Name: "title", Short: "t", Usage: tr.T("flag.dataset.title"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.dataset.description"), Required: true}, + {Name: "license-id", Usage: tr.T("flag.dataset.license_id")}, + {Name: "paper-content", Usage: tr.T("flag.dataset.paper_content")}, + {Name: "dry-run", Usage: tr.T("flag.dataset.dry_run"), Bool: true, Default: "false"}, + } + return []*common.Shortcut{ + { + Name: "view", + Description: tr.T("cmd.dataset.view.short"), + Long: tr.T("cmd.dataset.view.long"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.dataset.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.dataset.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + setIfPresent(q, "page", ctx.Arg("page")) + setIfPresent(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", repoDatasetPath(ctx), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "list", Description: tr.T("cmd.dataset.list.short"), @@ -39,50 +69,121 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { if err != nil { return err } - return queryDatasets(ctx, normalized) + q := url.Values{} + q.Set("ids", normalized) + env, err := ctx.CallAPIWithQuery("GET", "/v1/project_datasets", q) + if err != nil { + return err + } + return ctx.Output(env) }, }, { - Name: "view", - Description: tr.T("cmd.dataset.view.short"), - Long: tr.T("cmd.dataset.view.long"), + Name: "create", + Description: tr.T("cmd.dataset.create.short"), + Long: tr.T("cmd.dataset.create.long"), + Flags: writeFlags, + Run: runWrite("POST", "create_dataset"), + }, + { + Name: "update", + Description: tr.T("cmd.dataset.update.short"), + Long: tr.T("cmd.dataset.update.long"), + Flags: writeFlags, + Run: runWrite("PUT", "update_dataset"), + }, + { + Name: "delete-attachment", + Description: tr.T("cmd.dataset.delete_attachment.short"), + Long: tr.T("cmd.dataset.delete_attachment.long"), Flags: []common.Flag{ - {Name: "project-id", Usage: tr.T("flag.dataset.project_id")}, - }, - Run: func(ctx *common.RuntimeContext) error { - projectID := strings.TrimSpace(ctx.Arg("project-id")) - if projectID == "" { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - resolved, err := resolveProjectID(ctx) - if err != nil { - return err - } - projectID = strconv.FormatInt(resolved, 10) - } else if _, err := strconv.ParseInt(projectID, 10, 64); err != nil { - return fmt.Errorf("invalid --project-id %q: use a numeric project ID", projectID) - } - return queryDatasets(ctx, projectID) + {Name: "uuid", Short: "u", Usage: tr.T("flag.dataset.uuid"), Required: true}, + {Name: "dry-run", Usage: tr.T("flag.dataset.dry_run_delete"), Bool: true, Default: "false"}, + {Name: "yes", Usage: tr.T("flag.dataset.yes"), Bool: true, Default: "false"}, }, + Run: runDeleteAttachment, }, } } -// queryDatasets calls the platform dataset query endpoint with a comma-separated -// list of project IDs. -func queryDatasets(ctx *common.RuntimeContext, ids string) error { - q := url.Values{} - q.Set("ids", ids) - env, err := ctx.CallAPIWithQuery("GET", "/v1/project_datasets", q) +// runWrite builds the create/update handlers, which share the same request body. +func runWrite(method, action string) func(ctx *common.RuntimeContext) error { + return func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + body, err := datasetBody(ctx) + if err != nil { + return err + } + path := repoDatasetPath(ctx) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, "action": action, "method": method, "path": path, "body": body, + }) + } + env, err := ctx.CallAPI(method, path, body) + if err != nil { + return err + } + return ctx.Output(env) + } +} + +func runDeleteAttachment(ctx *common.RuntimeContext) error { + uuid, err := ctx.RequireArg("uuid") + if err != nil { + return err + } + uuid = strings.TrimSpace(uuid) + path := fmt.Sprintf("/attachments/%s", uuid) + if ctx.Arg("dry-run") == "true" { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, "action": "delete_dataset_attachment", "method": "DELETE", "path": path, "uuid": uuid, + }) + } + if ctx.Arg("yes") != "true" { + return fmt.Errorf("%s", ctx.Tr.T("error.dataset.delete_confirm")) + } + env, err := ctx.CallAPI("DELETE", path, nil) if err != nil { return err } return ctx.Output(env) } -// normalizeIDs validates a comma-separated list of positive integer project IDs -// and returns it without surrounding whitespace. +// datasetBody builds the create/update request body and validates inputs. +func datasetBody(ctx *common.RuntimeContext) (map[string]interface{}, error) { + title, err := ctx.RequireArg("title") + if err != nil { + return nil, err + } + description, err := ctx.RequireArg("description") + if err != nil { + return nil, err + } + body := map[string]interface{}{ + "title": strings.TrimSpace(title), + "description": strings.TrimSpace(description), + } + if v := strings.TrimSpace(ctx.Arg("license-id")); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return nil, fmt.Errorf("invalid --license-id %q: use a positive integer", v) + } + body["license_id"] = n + } + if v := strings.TrimSpace(ctx.Arg("paper-content")); v != "" { + body["paper_content"] = v + } + return body, nil +} + +func repoDatasetPath(ctx *common.RuntimeContext) string { + return "/v1" + ctx.RepoPath() + "/dataset" +} + +// normalizeIDs validates a comma-separated list of positive integer project IDs. func normalizeIDs(raw string) (string, error) { parts := strings.Split(raw, ",") cleaned := make([]string, 0, len(parts)) @@ -103,45 +204,10 @@ func normalizeIDs(raw string) (string, error) { return strings.Join(cleaned, ","), nil } -// resolveProjectID resolves the numeric GitLink project ID from the current -// owner/repo via the repository info endpoint. -func resolveProjectID(ctx *common.RuntimeContext) (int64, error) { - env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) - if err != nil { - return 0, fmt.Errorf("resolve project id: %w", err) +func setIfPresent(q url.Values, key, value string) { + if v := strings.TrimSpace(value); v != "" { + q.Set(key, v) } - data, ok := env.Data.(map[string]interface{}) - if !ok { - return 0, fmt.Errorf("resolve project id: unexpected repository response") - } - for _, key := range []string{"id", "project_id"} { - if id, ok := projectIDValue(data[key]); ok { - return id, nil - } - } - return 0, fmt.Errorf("resolve project id: repository response did not include id") -} - -func projectIDValue(value interface{}) (int64, bool) { - switch v := value.(type) { - case float64: - if v > 0 { - return int64(v), true - } - case int: - if v > 0 { - return int64(v), true - } - case int64: - if v > 0 { - return v, true - } - case string: - if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { - return n, true - } - } - return 0, false } func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { diff --git a/shortcuts/dataset/dataset_test.go b/shortcuts/dataset/dataset_test.go index 0aecaca..03e1674 100644 --- a/shortcuts/dataset/dataset_test.go +++ b/shortcuts/dataset/dataset_test.go @@ -2,6 +2,7 @@ package dataset import ( "encoding/json" + "io" "net/http" "net/http/httptest" "testing" @@ -39,6 +40,38 @@ func writeJSON(w http.ResponseWriter, v interface{}) { _ = json.NewEncoder(w).Encode(v) } +func decodeBody(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + data, _ := io.ReadAll(r.Body) + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("decode body: %v (raw: %s)", err, string(data)) + } + return m +} + +// --- view --- + +func TestDatasetView(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/alice/demo/dataset.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("limit = %q, want 5", got) + } + writeJSON(w, map[string]interface{}{"id": float64(1), "attachments": []interface{}{}}) + })) + defer server.Close() + + if err := runShortcut(t, server, "view", map[string]string{"page": "2", "limit": "5"}); err != nil { + t.Fatalf("view failed: %v", err) + } +} + // --- list --- func TestDatasetListNormalizesIDs(t *testing.T) { @@ -58,17 +91,6 @@ func TestDatasetListNormalizesIDs(t *testing.T) { } } -func TestDatasetListMissingIDs(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected when ids missing") - })) - defer server.Close() - - if err := runShortcut(t, server, "list", map[string]string{}); err == nil { - t.Fatal("expected error for missing --ids") - } -} - func TestDatasetListInvalidIDs(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected for invalid ids") @@ -80,71 +102,127 @@ func TestDatasetListInvalidIDs(t *testing.T) { } } -// --- view --- +// --- create --- -func TestDatasetViewResolvesProjectID(t *testing.T) { - var sawRepo, sawQuery bool +func TestDatasetCreate(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/alice/demo.json": - sawRepo = true - writeJSON(w, map[string]interface{}{"id": float64(5988)}) - case "/v1/project_datasets.json": - sawQuery = true - if got := r.URL.Query().Get("ids"); got != "5988" { - t.Fatalf("ids = %q, want 5988", got) - } - writeJSON(w, map[string]interface{}{"total_count": float64(1), "project_datasets": []interface{}{}}) - default: + if r.URL.Path != "/v1/alice/demo/dataset.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + body := decodeBody(t, r) + if body["title"] != "DS" || body["description"] != "desc" { + t.Fatalf("unexpected body: %v", body) + } + if body["license_id"] != float64(359) { + t.Fatalf("license_id = %v, want 359", body["license_id"]) + } + writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"}) })) defer server.Close() - if err := runShortcut(t, server, "view", map[string]string{}); err != nil { - t.Fatalf("view failed: %v", err) - } - if !sawRepo || !sawQuery { - t.Fatalf("expected repo+query calls, got repo=%v query=%v", sawRepo, sawQuery) + args := map[string]string{"title": "DS", "description": "desc", "license-id": "359", "paper-content": "x"} + if err := runShortcut(t, server, "create", args); err != nil { + t.Fatalf("create failed: %v", err) } } -func TestDatasetViewExplicitProjectID(t *testing.T) { +func TestDatasetCreateMissingTitle(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/project_datasets.json" { + t.Fatal("no API call expected without required flags") + })) + defer server.Close() + + if err := runShortcut(t, server, "create", map[string]string{"description": "desc"}); err == nil { + t.Fatal("expected error for missing --title") + } +} + +func TestDatasetCreateInvalidLicense(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected for invalid license id") + })) + defer server.Close() + + args := map[string]string{"title": "DS", "description": "desc", "license-id": "abc"} + if err := runShortcut(t, server, "create", args); err == nil { + t.Fatal("expected error for invalid --license-id") + } +} + +func TestDatasetCreateDryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no API call expected in dry-run") + })) + defer server.Close() + + args := map[string]string{"title": "DS", "description": "desc", "dry-run": "true"} + if err := runShortcut(t, server, "create", args); err != nil { + t.Fatalf("create dry-run failed: %v", err) + } +} + +// --- update --- + +func TestDatasetUpdate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/alice/demo/dataset.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - if got := r.URL.Query().Get("ids"); got != "42" { - t.Fatalf("ids = %q, want 42", got) + if r.Method != http.MethodPut { + t.Fatalf("method = %s, want PUT", r.Method) } - writeJSON(w, map[string]interface{}{"total_count": float64(0), "project_datasets": []interface{}{}}) + writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"}) })) defer server.Close() - if err := runShortcut(t, server, "view", map[string]string{"project-id": "42"}); err != nil { - t.Fatalf("view failed: %v", err) + args := map[string]string{"title": "DS2", "description": "desc2"} + if err := runShortcut(t, server, "update", args); err != nil { + t.Fatalf("update failed: %v", err) } } -func TestDatasetViewInvalidProjectID(t *testing.T) { +// --- delete-attachment --- + +func TestDatasetDeleteAttachmentDryRun(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatal("no API call expected for invalid project id") + t.Fatal("no API call expected in dry-run") })) defer server.Close() - if err := runShortcut(t, server, "view", map[string]string{"project-id": "x"}); err == nil { - t.Fatal("expected error for invalid --project-id") + args := map[string]string{"uuid": "abc-123", "dry-run": "true"} + if err := runShortcut(t, server, "delete-attachment", args); err != nil { + t.Fatalf("delete dry-run failed: %v", err) } } -func TestDatasetViewHTTPError(t *testing.T) { +func TestDatasetDeleteAttachmentRequiresConfirm(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("server error")) + t.Fatal("no API call expected without --yes") })) defer server.Close() - if err := runShortcut(t, server, "view", map[string]string{"project-id": "42"}); err == nil { - t.Fatal("expected error for HTTP 500") + if err := runShortcut(t, server, "delete-attachment", map[string]string{"uuid": "abc-123"}); err == nil { + t.Fatal("expected error without --yes confirmation") + } +} + +func TestDatasetDeleteAttachmentConfirmed(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/attachments/abc-123.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if r.Method != http.MethodDelete { + t.Fatalf("method = %s, want DELETE", r.Method) + } + writeJSON(w, map[string]interface{}{"status": float64(0), "message": "删除成功"}) + })) + defer server.Close() + + args := map[string]string{"uuid": "abc-123", "yes": "true"} + if err := runShortcut(t, server, "delete-attachment", args); err != nil { + t.Fatalf("delete failed: %v", err) } }