fix(dataset): guard unavailable backend endpoints #248

Closed
wangyue111 wants to merge 1 commits from wangyue111/gitlink-cli:fix/dataset-backend-guard into master
8 changed files with 135 additions and 81 deletions

View File

@ -628,22 +628,22 @@ paper content, license, owning project).
# List datasets for one or more projects (by numeric project ID)
gitlink-cli dataset +list --ids 5988
# View a repository's dataset and attachments
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# Preview a repository dataset request; live backend is guarded until Issue #21 is resolved
gitlink-cli dataset +view --owner Gitlink --repo forgeplus --dry-run
# Create / update a repository's dataset (preview first with --dry-run)
# Create / update preview only until the backend routes are deployed
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"
gitlink-cli dataset +update --owner me --repo proj -t "My dataset" -d "updated" --dry-run
# Delete a dataset attachment (destructive: preview, then confirm with --yes)
# Delete attachment preview only until the backend route is deployed
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: `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.
> Note: `dataset +list` (platform dataset query) is verified on production.
> The per-repo `+view`/`+create`/`+update` and attachment deletion routes are
> guarded in gitlink-cli until GitLink Issue #21 is resolved, because the
> corresponding backend routes currently return 404 on production. Use `--dry-run`
> to inspect the request contract without calling the unavailable endpoints.
### Raw API

View File

@ -506,19 +506,18 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
# 按数字项目 ID 列出一个或多个项目的数据集
gitlink-cli dataset +list --ids 5988
# 查看仓库的数据集及其附件
gitlink-cli dataset +view --owner Gitlink --repo forgeplus
# 预览仓库数据集请求Issue #21 解决前不会调用未部署后端
gitlink-cli dataset +view --owner Gitlink --repo forgeplus --dry-run
# 创建 / 更新仓库数据集(先用 --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 "更新"
gitlink-cli dataset +update --owner me --repo proj -t "我的数据集" -d "更新" --dry-run
# 删除数据集附件(破坏性:先预览,再用 --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
```
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404待平台上线后即可生效
> 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 和附件删除接口在 Issue #21 解决前会被 CLI 保护,不再直接调用当前生产环境会 404 的后端路由;可用 `--dry-run` 查看请求契约
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:

View File

@ -0,0 +1,38 @@
# Dataset Backend Guard for Issue #21
## Background
GitLink Issue #21 reports that PR #243 added dataset shortcuts for five endpoints, but production currently deploys only `GET /api/v1/project_datasets`. The repository-scoped dataset endpoints still return 404:
- `GET /api/v1/{owner}/{repo}/dataset`
- `POST /api/v1/{owner}/{repo}/dataset`
- `PUT /api/v1/{owner}/{repo}/dataset`
- `DELETE /api/attachments/{uuid}`
## What Changed
This change keeps the verified command available:
- `dataset +list --ids ...`
And guards the unavailable backend routes:
- `dataset +view` now supports `--dry-run` to preview the request and otherwise returns a clear Issue #21 error without calling the 404 endpoint.
- `dataset +create` and `dataset +update` keep `--dry-run` previews but return the same guard error for live writes.
- `dataset +delete-attachment` keeps `--dry-run` previews and `--yes` validation, then returns the guard error instead of calling the unavailable delete endpoint.
## Why
This prevents users and Agents from repeatedly hitting known 404 production routes while preserving the OpenAPI request contract for review and future backend rollout.
## Validation
```bash
git diff --check
GOPROXY=https://goproxy.cn,direct go test ./shortcuts/dataset ./shortcuts
go vet ./shortcuts/dataset ./shortcuts
go run . dataset +view --owner Gitlink --repo forgeplus --dry-run --format json
go run . dataset +create --owner Gitlink --repo forgeplus -t demo -d demo --dry-run --format json
GOPROXY=https://goproxy.cn,direct go test ./...
go vet ./...
```

View File

@ -133,6 +133,7 @@
"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.dry_run_view": "Preview the repository dataset request without calling the guarded backend endpoint",
"flag.dataset.ids": "Comma-separated project IDs to query datasets for",
"flag.dataset.license_id": "License ID",
"flag.dataset.limit": "Attachment page size",

View File

@ -133,6 +133,7 @@
"flag.dataset.description": "数据集描述",
"flag.dataset.dry_run": "预览请求,不写入数据集",
"flag.dataset.dry_run_delete": "预览请求,不删除附件",
"flag.dataset.dry_run_view": "预览仓库数据集请求,不调用受保护的后端接口",
"flag.dataset.ids": "用于查询数据集的项目 ID逗号分隔",
"flag.dataset.license_id": "许可证 ID",
"flag.dataset.limit": "附件每页数量",

View File

@ -18,6 +18,8 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const datasetBackendIssue = "GitLink dataset repository endpoints are not deployed yet; see https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues/21"
// Shortcuts returns dataset management and query shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
@ -38,6 +40,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
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"},
{Name: "dry-run", Usage: tr.T("flag.dataset.dry_run_view"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -46,11 +49,13 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
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
path := repoDatasetPath(ctx)
if ctx.Arg("dry-run") == "true" {
return ctx.OutputData(map[string]interface{}{
"dry_run": true, "action": "view_dataset", "method": "GET", "path": path, "query": q.Encode(),
})
}
return ctx.Output(env)
return datasetBackendUnavailable("dataset +view")
},
},
{
@ -122,11 +127,7 @@ func runWrite(method, action string) func(ctx *common.RuntimeContext) error {
"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)
return datasetBackendUnavailable("dataset +" + strings.TrimSuffix(action, "_dataset"))
}
}
@ -145,11 +146,11 @@ func runDeleteAttachment(ctx *common.RuntimeContext) error {
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)
return datasetBackendUnavailable("dataset +delete-attachment")
}
func datasetBackendUnavailable(command string) error {
return fmt.Errorf("%s is guarded because %s", command, datasetBackendIssue)
}
// datasetBody builds the create/update request body and validates inputs.

View File

@ -52,23 +52,25 @@ func decodeBody(t *testing.T, r *http.Request) map[string]interface{} {
// --- view ---
func TestDatasetView(t *testing.T) {
func TestDatasetViewDryRunDoesNotCallGuardedBackend(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{}{}})
t.Fatal("no API call expected in dry-run")
}))
defer server.Close()
if err := runShortcut(t, server, "view", map[string]string{"page": "2", "limit": "5"}); err != nil {
t.Fatalf("view failed: %v", err)
if err := runShortcut(t, server, "view", map[string]string{"page": "2", "limit": "5", "dry-run": "true"}); err != nil {
t.Fatalf("view dry-run failed: %v", err)
}
}
func TestDatasetViewGuardedUntilBackendIsAvailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("guarded backend endpoint should not be called")
}))
defer server.Close()
if err := runShortcut(t, server, "view", map[string]string{"page": "2", "limit": "5"}); err == nil {
t.Fatal("expected backend guard error")
}
}
@ -104,28 +106,15 @@ func TestDatasetListInvalidIDs(t *testing.T) {
// --- create ---
func TestDatasetCreate(t *testing.T) {
func TestDatasetCreateGuardedUntilBackendIsAvailable(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 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"})
t.Fatal("guarded backend endpoint should not be called")
}))
defer server.Close()
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)
if err := runShortcut(t, server, "create", args); err == nil {
t.Fatal("expected backend guard error")
}
}
@ -166,21 +155,15 @@ func TestDatasetCreateDryRun(t *testing.T) {
// --- update ---
func TestDatasetUpdate(t *testing.T) {
func TestDatasetUpdateGuardedUntilBackendIsAvailable(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 r.Method != http.MethodPut {
t.Fatalf("method = %s, want PUT", r.Method)
}
writeJSON(w, map[string]interface{}{"status": float64(0), "message": "success"})
t.Fatal("guarded backend endpoint should not be called")
}))
defer server.Close()
args := map[string]string{"title": "DS2", "description": "desc2"}
if err := runShortcut(t, server, "update", args); err != nil {
t.Fatalf("update failed: %v", err)
if err := runShortcut(t, server, "update", args); err == nil {
t.Fatal("expected backend guard error")
}
}
@ -209,20 +192,14 @@ func TestDatasetDeleteAttachmentRequiresConfirm(t *testing.T) {
}
}
func TestDatasetDeleteAttachmentConfirmed(t *testing.T) {
func TestDatasetDeleteAttachmentConfirmedIsGuardedUntilBackendIsAvailable(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": "删除成功"})
t.Fatal("guarded backend endpoint should not be called")
}))
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)
if err := runShortcut(t, server, "delete-attachment", args); err == nil {
t.Fatal("expected backend guard error")
}
}

View File

@ -0,0 +1,37 @@
---
name: gitlink-dataset
version: 1.0.0
description: "GitLink dataset query and guarded repository dataset request previews. Trigger when users need dataset +list or need to inspect repository dataset request contracts while Issue #21 backend routes are pending."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli dataset --help"
---
# GitLink Dataset Skill
Use this skill for GitLink research dataset operations.
## Available Production Command
```bash
# Query deployed platform dataset endpoint by numeric project IDs
gitlink-cli dataset +list --ids 5988 --format json
```
## Guarded Commands Pending Issue #21
The following repository-scoped dataset endpoints are documented by OpenAPI but are not deployed on production yet. Use `--dry-run` only until Issue #21 is resolved.
```bash
gitlink-cli dataset +view --owner OWNER --repo REPO --dry-run --format json
gitlink-cli dataset +create --owner OWNER --repo REPO -t "Dataset" -d "Description" --dry-run --format json
gitlink-cli dataset +update --owner OWNER --repo REPO -t "Dataset" -d "Description" --dry-run --format json
gitlink-cli dataset +delete-attachment --owner OWNER --repo REPO --uuid UUID --dry-run --format json
```
## Safety Rules
- Prefer `dataset +list` for production usage today.
- Do not run live `+view`, `+create`, `+update`, or `+delete-attachment` until GitLink Issue #21 is resolved.
- The CLI intentionally guards these live calls to avoid known production 404 responses.