mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
fix(cli): plug v0.3 final review findings (json + auth + path + bounds + kb)
Seven bugs surfaced via two audit rounds — parallel reviewer agents
plus a real-server end-to-end demo. Each fix arrives with a
regression test.
1. doc upload --recursive --json corrupted the envelope stream.
Per-file FAIL/OK plain lines printed unconditionally to stdout,
then a Success envelope, then on partial failure a typed error
that the root handler turned into a SECOND Failure envelope —
three outputs where one was expected. Fix: gate the plain lines
behind !opts.JSONOut, and add cmdutil.Error.Silent so the JSON-
path partial-failure preserves its typed exit code without
triggering PrintErrorEnvelope's default Failure-envelope write.
2. auth refresh / AuthRetryTransport misclassified HTTP failures as
network.error. RefreshAndPersist wrapped every refresher error
with CodeNetworkError, but the SDK emits "HTTP error 401: ..."
for a rejected refresh token — which should surface as
auth.token_expired. Switched to WrapHTTP for proper status-
derived classification. Affects both `auth refresh` and the
transport's refresh closure.
3. doc download accepted ".." as a server-suggested filename. The
rejection list covered "" / "." / filepath.Separator but not
bare ".." — filepath.Base("..") is "..", which slipped through
to os.Create and produced a confusing local.file_io wrap. Added
to the rejection set.
4. search chunks / docs / kb / sessions had no lower bound on
--limit. `-L 0` / `-L -1` was forwarded to the server with
undefined behavior. Added a 1..1000 bound at the RunE boundary
across all four (matching doc list / session list page-size
bounds). Internal callers in tests can still pass Limit==0 for
the "no client-side cap" runChunks path — the bound only applies
at the user-input layer.
5. cli/AGENTS.md ADR-3 verb-canon summary listed only v0.2 verbs as
"gh-canonical" and missed v0.3 additions (edit, pin, unpin,
download — all gh-canonical) plus locally-introduced ones
(empty, refresh, add, remove, link). Rewritten as an explicit
gh-canonical / locally-introduced split.
6. kb pin returned 404. Server registers /knowledge-bases/{id}/pin
as PUT (router.go:292); SDK was using POST. gin's router silently
404s on method-mismatch (treats it as path-not-found, not 405),
so the CLI classified the response as resource.not_found and
masked the real failure mode. Switched the SDK to http.MethodPut.
The asymmetry that hid this past round 1: kb unpin on a freshly-
created KB hits the no-op branch in cmd/kb/pin.go that skips the
SDK call entirely, so unpin "worked" without ever exercising the
broken path. Only the real-server demo, where kb pin actually
fires, surfaced it.
7. kb edit clobbered current Name when only --description was
passed. EditOptions used *string to distinguish "unset" from
"set to empty", but sdk.UpdateKnowledgeBaseRequest declares both
fields as plain string (no omitempty), so the JSON body always
carried `"name": ""`. Server requires Name → 400. Fix: runEdit
does fetch-then-update — GetKnowledgeBase first, build the PUT
body with current values, then overlay user-set fields. Same
TOCTOU window as kb pin / unpin.
Audit-flagged items intentionally NOT changed:
- kb pin / unpin check-then-toggle TOCTOU: documented; the clean
fix would be a server-side setter and belongs in a separate API
change.
- AuthRetryTransport singleflight test gap for one concurrency
scenario; v0.4 polish.
- cli/README.md:50 "once v0.2 ships" and CHANGELOG.md:8
"10 top-level commands": v0.2-PR artifacts, not v0.3-introduced.
- kb edit / kb pin are v0.3-new commands, so neither bug needs a
cli/CHANGELOG.md Fixed entry — the v0.3 release ships them
working as the Added bullets advertise.
This commit is contained in:
+1
-1
@@ -207,7 +207,7 @@ agent-aware error model. Documented deviations:
|
||||
misconfigured embeddings / storage / credentials, so a structured
|
||||
4-status diagnostic is the agent-readable surface for that.
|
||||
|
||||
Verb canon: `list / view / create / delete / upload / use` (all gh-canonical).
|
||||
Verb canon (gh-canonical): `list / view / create / edit / delete / upload / download / pin / unpin / use`. Locally introduced for resource semantics gh lacks: `empty` (bulk-delete contents preserving the container), `refresh` (token), `add` / `remove` (context CRUD), `link` (project bind).
|
||||
|
||||
**ADR-4 — Factory closures + narrow Service interfaces.** `cmdutil.Factory`
|
||||
exposes four lazy closures (Config / Client / Prompter / Secrets) that
|
||||
|
||||
@@ -102,7 +102,7 @@ func resolveDownloadDest(opts *DownloadOptions, suggested string) (string, error
|
||||
}
|
||||
}
|
||||
base := filepath.Base(suggested)
|
||||
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||
if base == "" || base == "." || base == ".." || base == string(filepath.Separator) {
|
||||
return "", &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("server returned an unusable filename %q", suggested),
|
||||
|
||||
@@ -124,6 +124,21 @@ func TestDownload_RejectsServerPathTraversal(t *testing.T) {
|
||||
assert.Equal(t, "exfil", string(got))
|
||||
}
|
||||
|
||||
// TestDownload_RejectsBareDotDot covers the literal-".." case: a server
|
||||
// returning Content-Disposition: attachment; filename=".." would, before
|
||||
// the rejection list was extended, pass `filepath.Base("..") == ".."`
|
||||
// through to os.Create and produce a confusing local.file_io wrap.
|
||||
func TestDownload_RejectsBareDotDot(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
for _, name := range []string{"..", "../"} {
|
||||
_, err := resolveDownloadDest(&DownloadOptions{}, name)
|
||||
require.Error(t, err, "filename=%q must be rejected", name)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_ForceOverwrites(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
dest := filepath.Join(t.TempDir(), "exists.bin")
|
||||
|
||||
@@ -91,7 +91,11 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadServ
|
||||
firstFailCode = code
|
||||
}
|
||||
failed = append(failed, uploadOutcome{Path: p, Error: err.Error()})
|
||||
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
|
||||
// Per-file progress lines are human progress signal; suppress
|
||||
// under --json so they don't precede the envelope on stdout.
|
||||
if !opts.JSONOut {
|
||||
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
id := ""
|
||||
@@ -99,7 +103,9 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadServ
|
||||
id = k.ID
|
||||
}
|
||||
uploaded = append(uploaded, uploadOutcome{Path: p, ID: id})
|
||||
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
|
||||
if !opts.JSONOut {
|
||||
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
@@ -114,9 +120,15 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, svc UploadServ
|
||||
}
|
||||
|
||||
if len(failed) > 0 {
|
||||
// Silent on the --json path: the success envelope above already
|
||||
// carries per-file uploaded[]/failed[] detail. Without Silent the
|
||||
// root error handler would write a second Failure envelope on
|
||||
// stdout, corrupting the stream. ExitCode still walks Code so the
|
||||
// typed exit-code-by-class contract is preserved.
|
||||
return &cmdutil.Error{
|
||||
Code: firstFailCode,
|
||||
Message: fmt.Sprintf("%d of %d uploads failed", len(failed), len(matches)),
|
||||
Silent: opts.JSONOut,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -168,6 +168,18 @@ func TestUploadRecursive_JSON_Envelope(t *testing.T) {
|
||||
assert.Contains(t, body, `"failed":`)
|
||||
assert.Contains(t, body, `ok.pdf`)
|
||||
assert.Contains(t, body, `bad.pdf`)
|
||||
|
||||
// --json must emit exactly ONE envelope. Per-file "FAIL"/"OK" progress
|
||||
// lines belong on the human path; the typed error is Silent so the root
|
||||
// handler doesn't write a second Failure envelope on top of ours.
|
||||
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --json")
|
||||
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --json")
|
||||
assert.Equal(t, 1, strings.Count(body, `"ok":`), "exactly one envelope on stdout")
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.True(t, typed.Silent, "JSON-path partial failure must be Silent")
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
func TestUploadRecursive_DryRun(t *testing.T) {
|
||||
|
||||
+31
-6
@@ -25,7 +25,13 @@ type EditOptions struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// EditService is the narrow SDK surface this command depends on. GetKnowledgeBase
|
||||
// is needed for the fetch-then-update flow: the server's UpdateKnowledgeBase
|
||||
// endpoint requires Name on the PUT body (UpdateKnowledgeBaseRequest.Name is
|
||||
// `string`, not `*string`, and the server validates `required`), so passing
|
||||
// only --description without fetching the current Name would 400.
|
||||
type EditService interface {
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
UpdateKnowledgeBase(ctx context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
@@ -72,7 +78,31 @@ func runEdit(ctx context.Context, opts *EditOptions, svc EditService, id string)
|
||||
}
|
||||
}
|
||||
|
||||
req := &sdk.UpdateKnowledgeBaseRequest{}
|
||||
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("edit knowledge base %s", id)}
|
||||
if opts.DryRun {
|
||||
// Dry-run renders only the user-set fields so the preview reflects
|
||||
// intent; the real-run fetch path fills in the rest from the server.
|
||||
preview := &sdk.UpdateKnowledgeBaseRequest{}
|
||||
if opts.Name != nil {
|
||||
preview.Name = *opts.Name
|
||||
}
|
||||
if opts.Description != nil {
|
||||
preview.Description = *opts.Description
|
||||
}
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, preview, &format.Meta{KBID: id}, risk)
|
||||
}
|
||||
|
||||
// Fetch current state so we can fill in fields the user didn't touch.
|
||||
// TOCTOU note: another writer could change Name/Description between
|
||||
// our Get and Put; matches the same race window kb pin / unpin have.
|
||||
current, err := svc.GetKnowledgeBase(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch knowledge base %s", id)
|
||||
}
|
||||
req := &sdk.UpdateKnowledgeBaseRequest{
|
||||
Name: current.Name,
|
||||
Description: current.Description,
|
||||
}
|
||||
if opts.Name != nil {
|
||||
req.Name = *opts.Name
|
||||
}
|
||||
@@ -80,11 +110,6 @@ func runEdit(ctx context.Context, opts *EditOptions, svc EditService, id string)
|
||||
req.Description = *opts.Description
|
||||
}
|
||||
|
||||
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("edit knowledge base %s", id)}
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, req, &format.Meta{KBID: id}, risk)
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateKnowledgeBase(ctx, id, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "edit knowledge base %s", id)
|
||||
|
||||
+39
-16
@@ -14,12 +14,25 @@ import (
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase.
|
||||
// fakeEditSvc captures the (id, request) pair handed to UpdateKnowledgeBase
|
||||
// and scripts the GetKnowledgeBase fetch used by the fetch-then-update path.
|
||||
type fakeEditSvc struct {
|
||||
gotID string
|
||||
gotReq *sdk.UpdateKnowledgeBaseRequest
|
||||
resp *sdk.KnowledgeBase
|
||||
err error
|
||||
current *sdk.KnowledgeBase // returned by GetKnowledgeBase
|
||||
currentErr error
|
||||
gotID string
|
||||
gotReq *sdk.UpdateKnowledgeBaseRequest
|
||||
resp *sdk.KnowledgeBase
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if f.currentErr != nil {
|
||||
return nil, f.currentErr
|
||||
}
|
||||
if f.current != nil {
|
||||
return f.current, nil
|
||||
}
|
||||
return &sdk.KnowledgeBase{ID: id}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) UpdateKnowledgeBase(_ context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error) {
|
||||
@@ -40,9 +53,16 @@ func TestEdit_RequiresAtLeastOneFlag(t *testing.T) {
|
||||
assert.Contains(t, typed.Hint, "--description")
|
||||
}
|
||||
|
||||
func TestEdit_OnlyName(t *testing.T) {
|
||||
// When only --name is passed, the request must carry the user's new name
|
||||
// AND the current Description (preserved via the fetch). Sending Description=""
|
||||
// would clobber the server-side value because UpdateKnowledgeBaseRequest
|
||||
// fields are `string`, not `*string`.
|
||||
func TestEdit_OnlyName_PreservesCurrentDescription(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new"}}
|
||||
svc := &fakeEditSvc{
|
||||
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "old", Description: "keep me"},
|
||||
resp: &sdk.KnowledgeBase{ID: "kb_abc", Name: "new", Description: "keep me"},
|
||||
}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("new")
|
||||
require.NoError(t, runEdit(context.Background(), opts, svc, "kb_abc"))
|
||||
@@ -50,24 +70,25 @@ func TestEdit_OnlyName(t *testing.T) {
|
||||
assert.Equal(t, "kb_abc", svc.gotID)
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "new", svc.gotReq.Name)
|
||||
// Description must be empty string (not "<nil>"), so server doesn't
|
||||
// confuse "unset" with "set-to-empty". Actually the SDK ships an empty
|
||||
// string either way — we just verify we didn't accidentally serialize a
|
||||
// description override.
|
||||
assert.Equal(t, "", svc.gotReq.Description)
|
||||
assert.Equal(t, "keep me", svc.gotReq.Description, "Description must be preserved from fetch when not in --description")
|
||||
assert.Contains(t, out.String(), "kb_abc")
|
||||
}
|
||||
|
||||
func TestEdit_OnlyDescription(t *testing.T) {
|
||||
// Symmetric: only --description must preserve current Name. The server's
|
||||
// `Name required` validation made this case fail without the fetch.
|
||||
func TestEdit_OnlyDescription_PreservesCurrentName(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{resp: &sdk.KnowledgeBase{ID: "kb_abc"}}
|
||||
svc := &fakeEditSvc{
|
||||
current: &sdk.KnowledgeBase{ID: "kb_abc", Name: "keep me", Description: "old"},
|
||||
resp: &sdk.KnowledgeBase{ID: "kb_abc"},
|
||||
}
|
||||
opts := &EditOptions{}
|
||||
opts.Description = stringPtr("new desc")
|
||||
require.NoError(t, runEdit(context.Background(), opts, svc, "kb_abc"))
|
||||
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "new desc", svc.gotReq.Description)
|
||||
assert.Equal(t, "", svc.gotReq.Name)
|
||||
assert.Equal(t, "keep me", svc.gotReq.Name, "Name must be preserved from fetch when not in --name")
|
||||
}
|
||||
|
||||
func TestEdit_BothFlags(t *testing.T) {
|
||||
@@ -95,7 +116,9 @@ func TestEdit_DryRun_JSON(t *testing.T) {
|
||||
|
||||
func TestEdit_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{err: errors.New("HTTP error 404: not found")}
|
||||
// 404 must come from the GetKnowledgeBase pre-fetch in the fetch-then-
|
||||
// update flow — that's the first server roundtrip when the id is bad.
|
||||
svc := &fakeEditSvc{currentErr: errors.New("HTTP error 404: not found")}
|
||||
opts := &EditOptions{}
|
||||
opts.Name = stringPtr("x")
|
||||
err := runEdit(context.Background(), opts, svc, "kb_missing")
|
||||
|
||||
@@ -51,6 +51,9 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
|
||||
if err := opts.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -81,7 +84,9 @@ func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) {
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
}
|
||||
|
||||
// validate checks the option set before any SDK call.
|
||||
// validate checks the option set before any SDK call. Limit bounds are
|
||||
// enforced separately in RunE (user-input boundary) so internal callers
|
||||
// can pass Limit==0 for the "no client-side cap" path.
|
||||
func (o *ChunksOptions) validate() error {
|
||||
if o.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
|
||||
@@ -54,6 +54,9 @@ func NewCmdDocs(f *cmdutil.Factory) *cobra.Command {
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewCmdSessions(f *cmdutil.Factory) *cobra.Command {
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 1000 {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -38,7 +38,10 @@ func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refre
|
||||
|
||||
resp, err := refresher.RefreshToken(ctx, refresh)
|
||||
if err != nil {
|
||||
return "", Wrapf(CodeNetworkError, err, "refresh access token")
|
||||
// WrapHTTP rather than fixed CodeNetworkError so a refresh
|
||||
// rejected by the server (401/403) surfaces as auth.token_expired /
|
||||
// auth.forbidden instead of collapsing to network.error.
|
||||
return "", WrapHTTP(err, "refresh access token")
|
||||
}
|
||||
if resp == nil || !resp.Success || resp.AccessToken == "" || resp.RefreshToken == "" {
|
||||
msg := "refresh token rejected"
|
||||
|
||||
@@ -94,6 +94,12 @@ type Error struct {
|
||||
// Stored as the format.Risk JSON shape via OperationRisk to avoid an
|
||||
// import cycle with internal/format.
|
||||
OperationRisk *OperationRisk
|
||||
// Silent suppresses the default Failure envelope written by
|
||||
// PrintErrorEnvelope while preserving the typed Code for ExitCode.
|
||||
// Set by commands that already wrote their own envelope (e.g. bulk
|
||||
// operations reporting partial-success data) but still need to surface
|
||||
// a non-zero exit code matched to the failure class.
|
||||
Silent bool
|
||||
}
|
||||
|
||||
// OperationRisk mirrors format.Risk in the cmdutil layer (avoiding a circular
|
||||
|
||||
@@ -89,6 +89,10 @@ func PrintErrorEnvelope(w io.Writer, err error) {
|
||||
if err == nil || errors.Is(err, SilentError) {
|
||||
return
|
||||
}
|
||||
var typed *Error
|
||||
if errors.As(err, &typed) && typed.Silent {
|
||||
return
|
||||
}
|
||||
env := format.Failure(ToErrorBody(err))
|
||||
if r := operationRiskOf(err); r != nil {
|
||||
env.Risk = &format.Risk{Level: format.RiskLevel(r.Level), Action: r.Action}
|
||||
|
||||
@@ -371,10 +371,13 @@ func (c *Client) HybridSearch(ctx context.Context, knowledgeBaseID string, param
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
|
||||
// TogglePinKnowledgeBase toggles the pin status of a knowledge base.
|
||||
// Server route is PUT (see internal/router/router.go); using POST silently
|
||||
// 404s — the router treats unknown method on a known path as not-found,
|
||||
// not 405.
|
||||
func (c *Client) TogglePinKnowledgeBase(ctx context.Context, knowledgeBaseID string) (*KnowledgeBase, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/pin", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user