forked from Gitlink/gitlink-cli
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 <noreply@anthropic.com>
This commit is contained in:
parent
7f731eb04f
commit
29ca94db76
20
README.md
20
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 <uuid> --dry-run
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <uuid> --dry-run
|
||||
gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --yes
|
||||
```
|
||||
|
||||
> 注意:生产环境 gitlink.org.cn 仅提供平台级数据集查询端点;按仓库的数据集增删改查路由尚未在生产部署。
|
||||
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。
|
||||
|
||||
### Raw API
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <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 <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` 正整数校验。
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 的认证连通性检查",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue