mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
21 Commits
coderabbit
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7be4205d0 | ||
|
|
9b05a71de3 | ||
|
|
c906fcac7e | ||
|
|
6ddbbafc4f | ||
|
|
bf9264c901 | ||
|
|
e9f8d1d94b | ||
|
|
a520b7ca93 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 | ||
|
|
1c92ed8841 | ||
|
|
644c3c77dd | ||
|
|
bd898a1d74 | ||
|
|
898e6d4b3b | ||
|
|
7df37ed715 | ||
|
|
3f9ace8af5 | ||
|
|
b3514e5519 |
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -70,6 +71,7 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -87,6 +89,23 @@ jobs:
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,8 +128,28 @@ jobs:
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Write public content metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
mkdir -p .tmp/quality-gate
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"title": os.environ.get("PR_TITLE", ""),
|
||||
"body": os.environ.get("PR_BODY", ""),
|
||||
"branch": os.environ.get("PR_BRANCH", ""),
|
||||
}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
- name: Run CLI deterministic gate
|
||||
run: make quality-gate
|
||||
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -220,7 +259,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -241,7 +280,7 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -333,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -345,6 +384,7 @@ jobs:
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -361,6 +401,7 @@ jobs:
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
77
.github/workflows/semantic-review.yml
vendored
77
.github/workflows/semantic-review.yml
vendored
@@ -88,31 +88,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -121,6 +134,11 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
@@ -299,31 +317,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -332,6 +363,16 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
@@ -389,6 +430,10 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,6 +7,11 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -46,3 +51,4 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
5
Makefile
5
Makefile
@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
|
||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
@@ -69,7 +70,8 @@ integration-test: build
|
||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||
|
||||
quality-gate: build
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
@@ -89,6 +91,7 @@ quality-gate: build
|
||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||
|
||||
install: build
|
||||
|
||||
132
events/im/card_action.go
Normal file
132
events/im/card_action.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||
type CardActionTriggerOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||
}
|
||||
|
||||
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Operator struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"operator"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
Action struct {
|
||||
Tag string `json:"tag"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
Name string `json:"name"`
|
||||
FormValue map[string]interface{} `json:"form_value"`
|
||||
InputValue string `json:"input_value"`
|
||||
Option string `json:"option"`
|
||||
Options []string `json:"options"`
|
||||
Checked bool `json:"checked"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"action"`
|
||||
Context struct {
|
||||
OpenMessageID string `json:"open_message_id"`
|
||||
OpenChatID string `json:"open_chat_id"`
|
||||
} `json:"context"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||
}
|
||||
|
||||
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||
|
||||
out := &CardActionTriggerOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
OperatorID: envelope.Event.Operator.OpenID,
|
||||
MessageID: envelope.Event.Context.OpenMessageID,
|
||||
ChatID: envelope.Event.Context.OpenChatID,
|
||||
Host: envelope.Event.Host,
|
||||
Token: envelope.Event.Token,
|
||||
ActionTag: envelope.Event.Action.Tag,
|
||||
ActionValue: actionValue,
|
||||
ActionName: envelope.Event.Action.Name,
|
||||
FormValue: formValue,
|
||||
InputValue: envelope.Event.Action.InputValue,
|
||||
Option: envelope.Event.Action.Option,
|
||||
Options: options,
|
||||
Checked: envelope.Event.Action.Checked,
|
||||
Timezone: envelope.Event.Action.Timezone,
|
||||
}
|
||||
|
||||
if out.MessageID != "" && rt != nil {
|
||||
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// fetchCardUserDSL gets the card message content via message get API.
|
||||
// Returns empty string on any failure — never blocks event consumption.
|
||||
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Data.Items[0].Body.Content
|
||||
}
|
||||
|
||||
func marshalToString(m map[string]interface{}) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
432
events/im/card_action_test.go
Normal file
432
events/im/card_action_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("card.action.trigger")
|
||||
if !ok {
|
||||
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("card.action.trigger must set Schema.Custom")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("card.action.trigger must set Process")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Button(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_btn_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_operator"},
|
||||
"token": "c-token-btn",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "approve"},
|
||||
"name": "approve_btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_msg_001",
|
||||
"open_chat_id": "oc_chat_001"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Type != "card.action.trigger" {
|
||||
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_btn_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" {
|
||||
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
if out.ActionValue != `{"key":"approve"}` {
|
||||
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||
}
|
||||
if out.ActionName != "approve_btn" {
|
||||
t.Errorf("ActionName = %q", out.ActionName)
|
||||
}
|
||||
if out.Token != "c-token-btn" {
|
||||
t.Errorf("Token = %q", out.Token)
|
||||
}
|
||||
if out.MessageID != "om_msg_001" {
|
||||
t.Errorf("MessageID = %q", out.MessageID)
|
||||
}
|
||||
if out.ChatID != "oc_chat_001" {
|
||||
t.Errorf("ChatID = %q", out.ChatID)
|
||||
}
|
||||
if out.Host != "im_message" {
|
||||
t.Errorf("Host = %q", out.Host)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_form_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_form_user"},
|
||||
"token": "c-token-form",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "submit_btn",
|
||||
"form_value": {"name": "test-user", "reason": "testing"},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_form_001",
|
||||
"open_chat_id": "oc_chat_002"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||
t.Errorf("FormValue = %q", out.FormValue)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ms_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_ms_user"},
|
||||
"token": "c-token-ms",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "multi_select_static",
|
||||
"value": {},
|
||||
"name": "multi_select",
|
||||
"options": ["opt_1", "opt_3"],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_ms_001",
|
||||
"open_chat_id": "oc_chat_003"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Options != "opt_1,opt_3" {
|
||||
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||
}
|
||||
if out.ActionTag != "multi_select_static" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Input(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_input_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469276"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_input_user"},
|
||||
"token": "c-token-input",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "input",
|
||||
"value": {},
|
||||
"name": "text_input",
|
||||
"input_value": "hello world",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_input_001",
|
||||
"open_chat_id": "oc_chat_004"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.InputValue != "hello world" {
|
||||
t.Errorf("InputValue = %q", out.InputValue)
|
||||
}
|
||||
if out.ActionTag != "input" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_date_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469277"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_date_user"},
|
||||
"token": "c-token-date",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "date_picker",
|
||||
"value": {},
|
||||
"name": "date_selector",
|
||||
"option": "2024-04-01 +0800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_date_001",
|
||||
"open_chat_id": "oc_chat_005"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Option != "2024-04-01 +0800" {
|
||||
t.Errorf("Option = %q", out.Option)
|
||||
}
|
||||
if out.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("Timezone = %q", out.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ok",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469278"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user"},
|
||||
"token": "c-token-mg",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "click"},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_001",
|
||||
"open_chat_id": "oc_chat_mg"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||
mock := &mockAPIClient{resp: `{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [{
|
||||
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent == "" {
|
||||
t.Error("CardContent should not be empty when message get succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ec",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469279"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user2"},
|
||||
"token": "c-token-mg2",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_002",
|
||||
"open_chat_id": "oc_chat_mg2"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_fail",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469280"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user3"},
|
||||
"token": "c-token-mg3",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_003",
|
||||
"open_chat_id": "oc_chat_mg3"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{errResp: true}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_no_msg",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469281"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_no_msg"},
|
||||
"token": "c-token-nm",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "",
|
||||
"open_chat_id": "oc_chat_nm"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
type mockAPIClient struct {
|
||||
resp string
|
||||
errResp bool
|
||||
}
|
||||
|
||||
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if m.errResp {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return json.RawMessage(m.resp), nil
|
||||
}
|
||||
|
||||
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out CardActionTriggerOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
{
|
||||
Key: "card.action.trigger",
|
||||
DisplayName: "Card action",
|
||||
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
EventType: "card.action.trigger",
|
||||
SubscriptionType: event.SubTypeCallback,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||
},
|
||||
Process: processCardAction,
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
SingleConsumer: true,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
|
||||
@@ -131,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,22 @@
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
33
internal/binding/audit_windows_test.go
Normal file
33
internal/binding/audit_windows_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp command: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "exec provider command",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
flag.Parse()
|
||||
|
||||
if *eventPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
report.Print(os.Stderr, diags)
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (string, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||
out := make([]report.Diagnostic, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, report.Diagnostic{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := commentBody(path)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if err == nil || !ok {
|
||||
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
|
||||
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
|
||||
got := auditFailureSummary(2)
|
||||
want := "post-publication audit found public content findings: 2"
|
||||
if got != want {
|
||||
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(path, data string) error {
|
||||
return os.WriteFile(path, []byte(data), 0o644)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/qualitygate/rules"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
|
||||
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
|
||||
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
|
||||
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
|
||||
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
|
||||
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
|
||||
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
if opts.PublicContentMetadataPath != "" {
|
||||
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
opts.PublicContentMetadataPath = safePath
|
||||
}
|
||||
|
||||
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
|
||||
return 2
|
||||
|
||||
@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
|
||||
code, stderr := runCheckCaptureStderr(t, []string{
|
||||
"--repo", t.TempDir(),
|
||||
"--cli-bin", "./lark-cli",
|
||||
"--public-content-metadata", ".tmp/quality-gate/pr.json",
|
||||
})
|
||||
if code != 2 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "flag provided but not defined") {
|
||||
t.Fatalf("public content metadata flag was not registered: %s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--manifest and --command-index are required") {
|
||||
t.Fatalf("stderr = %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
|
||||
code, stderr := runCheckCaptureStderr(t, []string{
|
||||
"--repo", t.TempDir(),
|
||||
"--cli-bin", "./lark-cli",
|
||||
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
|
||||
})
|
||||
if code != 2 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
|
||||
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, "command-manifest.json")
|
||||
|
||||
@@ -56,6 +56,14 @@ func run(args []string) int {
|
||||
_ = semantic.WriteMarkdown(markdownOut, decision)
|
||||
return 0
|
||||
}
|
||||
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
|
||||
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return decisionExitCode(decision)
|
||||
}
|
||||
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
@@ -72,6 +80,15 @@ func run(args []string) int {
|
||||
return 0
|
||||
}
|
||||
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
|
||||
decision = finalizeDecision(block, waiverDiags, decision)
|
||||
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return decisionExitCode(decision)
|
||||
}
|
||||
|
||||
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
|
||||
decision.BlockMode = block
|
||||
if !block && len(decision.Blockers) > 0 {
|
||||
for i := range decision.Blockers {
|
||||
@@ -81,15 +98,21 @@ func run(args []string) int {
|
||||
decision.Blockers = nil
|
||||
}
|
||||
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
|
||||
return decision
|
||||
}
|
||||
|
||||
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
|
||||
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
|
||||
return 2
|
||||
return fmt.Errorf("write decision: %w", err)
|
||||
}
|
||||
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
|
||||
return 2
|
||||
return fmt.Errorf("write markdown: %w", err)
|
||||
}
|
||||
if block && len(decision.Blockers) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decisionExitCode(decision semantic.Decision) int {
|
||||
if decision.BlockMode && len(decision.Blockers) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Skills: []facts.SkillFact{{
|
||||
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||
Line: 30,
|
||||
Changed: true,
|
||||
ReferencesInvalidCommand: true,
|
||||
}},
|
||||
}
|
||||
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
|
||||
t.Setenv("ARK_API_KEY", "")
|
||||
t.Setenv("ARK_BASE_URL", "")
|
||||
t.Setenv("ARK_MODEL", "")
|
||||
|
||||
repo := t.TempDir()
|
||||
writeSemanticConfig(t, repo, `{
|
||||
"schema_version": 1,
|
||||
"default_enforcement": "observe",
|
||||
"block_categories": ["skill_quality"]
|
||||
}`, `{
|
||||
"allowed": ["semantic-review-v1"],
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Commands: []facts.CommandFact{{
|
||||
Path: "service command 1",
|
||||
Domain: "service",
|
||||
Changed: true,
|
||||
Source: "service",
|
||||
}},
|
||||
Outputs: []facts.OutputFact{{
|
||||
Command: "service command 1",
|
||||
Domain: "service",
|
||||
Changed: true,
|
||||
Source: "service",
|
||||
IsList: true,
|
||||
HasDefaultLimit: true,
|
||||
HasDecisionField: true,
|
||||
}},
|
||||
}
|
||||
if semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must not contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
|
||||
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
|
||||
if code != 0 {
|
||||
t.Fatalf("run() = %d, want clean pass", code)
|
||||
}
|
||||
decision := readDecision(t, decisionPath)
|
||||
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
|
||||
t.Fatalf("expected non-degraded pass decision: %#v", decision)
|
||||
}
|
||||
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
|
||||
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
|
||||
}
|
||||
data, err := os.ReadFile(markdownPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read markdown: %v", err)
|
||||
}
|
||||
markdown := string(data)
|
||||
if !strings.Contains(markdown, "No semantic blockers.") {
|
||||
t.Fatalf("markdown missing pass summary: %s", markdown)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
|
||||
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
|
||||
t.Setenv("ARK_API_KEY", "test-key")
|
||||
t.Setenv("ARK_BASE_URL", "")
|
||||
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Skills: []facts.SkillFact{{
|
||||
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||
Line: 30,
|
||||
Changed: true,
|
||||
ReferencesInvalidCommand: true,
|
||||
}},
|
||||
}
|
||||
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"error_hint",
|
||||
"default_output",
|
||||
"naming",
|
||||
"skill_quality"
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
],
|
||||
"rollout_groups": [
|
||||
{
|
||||
@@ -16,7 +17,8 @@
|
||||
},
|
||||
"categories": [
|
||||
"error_hint",
|
||||
"skill_quality"
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
],
|
||||
"owner": "cli-owner",
|
||||
"reason": "first semantic blocking rollout only affects changed facts"
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Facts struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Commands []CommandFact `json:"commands,omitempty"`
|
||||
Skills []SkillFact `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorFact `json:"errors,omitempty"`
|
||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||
Examples []CommandExample `json:"examples,omitempty"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Commands []CommandFact `json:"commands,omitempty"`
|
||||
Skills []SkillFact `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorFact `json:"errors,omitempty"`
|
||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||
Examples []CommandExample `json:"examples,omitempty"`
|
||||
PublicContent []PublicContentFact `json:"public_content,omitempty"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type CommandFact struct {
|
||||
@@ -109,6 +110,17 @@ type OutputFact struct {
|
||||
HasDecisionField bool `json:"has_decision_field,omitempty"`
|
||||
}
|
||||
|
||||
type PublicContentFact struct {
|
||||
Rule string `json:"rule"`
|
||||
Action report.Action `json:"action"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
type DryRunRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
|
||||
}
|
||||
}
|
||||
|
||||
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
|
||||
f.PublicContent = publicContent
|
||||
return f
|
||||
}
|
||||
|
||||
type commandScope struct {
|
||||
Domain string
|
||||
Source string
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
||||
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
|
||||
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
|
||||
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
|
||||
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
|
||||
}
|
||||
data, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal facts: %v", err)
|
||||
}
|
||||
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
|
||||
if !got.Errors[0].RequiredHint ||
|
||||
got.Outputs[0].Fields[0] != "message_id" ||
|
||||
!got.Skills[0].ScopeConflict ||
|
||||
got.PublicContent[0].Rule != "public_content_generic_credential" {
|
||||
t.Fatalf("facts lost gatekeeper fields: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
343
internal/qualitygate/publiccontent/collect.go
Normal file
343
internal/qualitygate/publiccontent/collect.go
Normal file
@@ -0,0 +1,343 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
|
||||
metadata, err := LoadMetadata(opts.MetadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Finding
|
||||
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches := map[string][]changedChunk{}
|
||||
if base != "" {
|
||||
patches, err = changedPatches(ctx, opts.Repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, file := range changedFiles {
|
||||
if !scanChangedFile(file) {
|
||||
continue
|
||||
}
|
||||
for _, chunk := range patches[file] {
|
||||
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
|
||||
for i := range findings {
|
||||
findings[i].Line += chunk.StartLine - 1
|
||||
}
|
||||
out = append(out, findings...)
|
||||
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
|
||||
}
|
||||
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = appendUniqueFindings(out, privateKeyFindings...)
|
||||
}
|
||||
if base != "" {
|
||||
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, commitFindings...)
|
||||
}
|
||||
branchName := opts.BranchName
|
||||
if branchName == "" {
|
||||
branchName = metadata.Branch
|
||||
}
|
||||
if branchName == "" {
|
||||
branchName = branchFromEnv()
|
||||
}
|
||||
if branchName == "" {
|
||||
branchName = currentBranch(ctx, opts.Repo)
|
||||
}
|
||||
if branchName != "" {
|
||||
out = append(out, scanText("branch", "branch", branchName, false)...)
|
||||
}
|
||||
out = append(out, scanMetadata(metadata)...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func currentBranch(ctx context.Context, repo string) string {
|
||||
data, err := gitOutput(ctx, repo, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func branchFromEnv() string {
|
||||
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
|
||||
if changedFrom == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
base := strings.TrimSpace(string(baseBytes))
|
||||
files, err := diffFileNames(ctx, repo, base)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, base, nil
|
||||
}
|
||||
|
||||
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
|
||||
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, file := range bytes.Split(data, []byte{0}) {
|
||||
if len(file) == 0 {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.ToSlash(string(file)))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
var detectorFixtureExclusions = map[string]bool{
|
||||
"internal/qualitygate/publiccontent/collect_test.go": true,
|
||||
"internal/qualitygate/publiccontent/rules.go": true,
|
||||
"internal/qualitygate/publiccontent/scan.go": true,
|
||||
"internal/qualitygate/publiccontent/scan_test.go": true,
|
||||
}
|
||||
|
||||
func scanChangedFile(file string) bool {
|
||||
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
|
||||
return !detectorFixtureExclusions[normalized]
|
||||
}
|
||||
|
||||
type changedChunk struct {
|
||||
StartLine int
|
||||
Text string
|
||||
}
|
||||
|
||||
func (c changedChunk) endLine() int {
|
||||
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
|
||||
if lines < 1 {
|
||||
lines = 1
|
||||
}
|
||||
return c.StartLine + lines - 1
|
||||
}
|
||||
|
||||
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
|
||||
files, err := diffFileNames(ctx, repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]changedChunk{}
|
||||
var file string
|
||||
var chunk *changedChunk
|
||||
nextLine := 0
|
||||
nextFile := 0
|
||||
flush := func() {
|
||||
if file == "" || chunk == nil || chunk.Text == "" {
|
||||
chunk = nil
|
||||
return
|
||||
}
|
||||
out[file] = append(out[file], *chunk)
|
||||
chunk = nil
|
||||
}
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "diff --git "):
|
||||
flush()
|
||||
file = ""
|
||||
if nextFile < len(files) {
|
||||
file = files[nextFile]
|
||||
nextFile++
|
||||
}
|
||||
case strings.HasPrefix(raw, "@@ "):
|
||||
flush()
|
||||
start, ok := parseNewHunkStart(raw)
|
||||
if !ok {
|
||||
nextLine = 0
|
||||
continue
|
||||
}
|
||||
nextLine = start
|
||||
chunk = &changedChunk{StartLine: start}
|
||||
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
|
||||
if chunk == nil {
|
||||
chunk = &changedChunk{StartLine: max(nextLine, 1)}
|
||||
}
|
||||
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
|
||||
nextLine++
|
||||
case strings.HasPrefix(raw, "-"):
|
||||
continue
|
||||
default:
|
||||
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseNewHunkStart(header string) (int, bool) {
|
||||
parts := strings.Split(header, " ")
|
||||
for _, part := range parts {
|
||||
if !strings.HasPrefix(part, "+") {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimPrefix(part, "+")
|
||||
if before, _, ok := strings.Cut(raw, ","); ok {
|
||||
raw = before
|
||||
}
|
||||
start, err := strconv.Atoi(raw)
|
||||
return start, err == nil && start > 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
|
||||
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := bytes.Split(data, []byte{0})
|
||||
var out []Finding
|
||||
for i := 0; i+1 < len(parts); i += 2 {
|
||||
sha := strings.TrimSpace(string(parts[i]))
|
||||
body := string(parts[i+1])
|
||||
if sha == "" || body == "" {
|
||||
continue
|
||||
}
|
||||
short := sha
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
out = append(out, scanText("commit:"+short, "commit", body, false)...)
|
||||
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type lineRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
|
||||
if len(chunks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var added []lineRange
|
||||
for _, chunk := range chunks {
|
||||
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
|
||||
}
|
||||
var out []Finding
|
||||
for _, block := range privateKeyBlocks(string(data)) {
|
||||
if !rangesIntersectAny(block, added) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func privateKeyBlocks(text string) []lineRange {
|
||||
lines := strings.Split(text, "\n")
|
||||
var out []lineRange
|
||||
inPrivateKey := false
|
||||
start := 0
|
||||
for i, line := range lines {
|
||||
lineNo := i + 1
|
||||
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = true
|
||||
start = lineNo
|
||||
}
|
||||
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, lineRange{Start: start, End: lineNo})
|
||||
inPrivateKey = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
|
||||
for _, r := range ranges {
|
||||
if block.Start <= r.End && r.Start <= block.End {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
|
||||
for _, addition := range additions {
|
||||
duplicate := false
|
||||
for _, item := range items {
|
||||
if item.Rule == addition.Rule &&
|
||||
item.File == addition.File &&
|
||||
item.Line == addition.Line &&
|
||||
item.Source == addition.Source {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
items = append(items, addition)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = repo
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
@@ -0,0 +1,885 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
|
||||
`)
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "example-public-key"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
|
||||
rules := findingRules(got)
|
||||
for _, want := range []string{
|
||||
"public_content_generic_credential",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_reviewed_on_trailer",
|
||||
} {
|
||||
if !rules[want] {
|
||||
t.Fatalf("missing rule %s in findings %#v", want, got)
|
||||
}
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.File == "baseline.md" {
|
||||
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
|
||||
runGit(t, repo, "add", "docs/workflow.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
|
||||
runGit(t, repo, "add", "docs/workflow.md")
|
||||
runGit(t, repo, "commit", "-m", "add public line")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
|
||||
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, item := range got {
|
||||
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
|
||||
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
|
||||
}
|
||||
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
|
||||
t.Fatalf("semantic candidate leaked credential value: %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
|
||||
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
|
||||
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||
}
|
||||
|
||||
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
token := "abcdefghijklmnopqrstuvwxyz"
|
||||
raw := "private launch plan for internal rollout on Friday with " +
|
||||
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add json bearer")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
|
||||
for _, item := range got {
|
||||
if item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item.Excerpt, token) {
|
||||
t.Fatalf("finding leaked JSON bearer token: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"tokenizer":"cl100k_base"}`,
|
||||
`{"token_count": 42}`,
|
||||
`{"page_token":"next"}`,
|
||||
`{"next_page_token":"next"}`,
|
||||
`{"file_token":"file-example"}`,
|
||||
`{"doc_token":"doc-example"}`,
|
||||
`{"node_token":"node-example"}`,
|
||||
`{"wiki_token":"wikcn_public_doc_example"}`,
|
||||
`{"folder_token":"folder-example"}`,
|
||||
`{"obj_token":"obj-example"}`,
|
||||
`{"spreadsheet_token":"sheet-example"}`,
|
||||
`{"parent_node_token":"parent-example"}`,
|
||||
`{"origin_node_token":"origin-example"}`,
|
||||
`{"drive_route_token":"route-example"}`,
|
||||
`{"token":"<wiki_token>"}`,
|
||||
`{"token":"wiki_token"}`,
|
||||
`{"token_url":"https://example.com/oauth/token"}`,
|
||||
`{"token_endpoint":"https://example.com/oauth/token"}`,
|
||||
`{"token_format":"Bearer"}`,
|
||||
`{"secret_name":"public-example-secret"}`,
|
||||
`{"base_token":"base-example"}`,
|
||||
`{"app_token":"app-example"}`,
|
||||
`{"sync_token":"sync-example"}`,
|
||||
`{"parent_token":"parent-example"}`,
|
||||
`{"target_token":"target-example"}`,
|
||||
`{"parent_file_token":"parent-file-example"}`,
|
||||
`{"refresh_token_expires_in": 7200}`,
|
||||
`{"access_token_expires_in": 7200}`,
|
||||
`{"token_expires_in": 7200}`,
|
||||
`{"token_status":"active"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add benign json token fields")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY: " + accessKey,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add access key config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"PRIVATE_KEY: " + privateKey,
|
||||
"SSH_PRIVATE_KEY: " + privateKey,
|
||||
"JWT_PRIVATE_KEY: " + privateKey,
|
||||
"SIGNING_PRIVATE_KEY: " + privateKey,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add private key config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, privateKey) {
|
||||
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"tokens: 128",
|
||||
"token_type: bearer",
|
||||
"max_tokens: 2000",
|
||||
"completion_tokens: 200",
|
||||
"prompt_tokens: 100",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add benign token config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 5 {
|
||||
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "complete key")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "complete key")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "add body")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "docs update")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||
t.Fatalf("collector reported untouched historical private key: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "remove body")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
var foundOrdinaryTestLeak bool
|
||||
for _, item := range got {
|
||||
switch item.File {
|
||||
case "internal/qualitygate/publiccontent/collect_test.go",
|
||||
"internal/qualitygate/publiccontent/scan.go",
|
||||
"internal/qualitygate/publiccontent/scan_test.go",
|
||||
"internal/qualitygate/publiccontent/rules.go":
|
||||
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
|
||||
}
|
||||
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
|
||||
foundOrdinaryTestLeak = true
|
||||
}
|
||||
}
|
||||
if !foundOrdinaryTestLeak {
|
||||
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
|
||||
excluded := []string{
|
||||
"internal/qualitygate/publiccontent/collect_test.go",
|
||||
"internal/qualitygate/publiccontent/rules.go",
|
||||
"internal/qualitygate/publiccontent/scan.go",
|
||||
"internal/qualitygate/publiccontent/scan_test.go",
|
||||
}
|
||||
for _, file := range excluded {
|
||||
if scanChangedFile(file) {
|
||||
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
|
||||
}
|
||||
}
|
||||
|
||||
included := []string{
|
||||
"internal/qualitygate/publiccontent/new_test.go",
|
||||
"tests/e2e/new-public-workflow.test.sh",
|
||||
"docs/public.md",
|
||||
}
|
||||
for _, file := range included {
|
||||
if !scanChangedFile(file) {
|
||||
t.Fatalf("scanChangedFile(%q) = false, want true", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
|
||||
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
|
||||
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
|
||||
}
|
||||
|
||||
func TestCollectScansBranchNameAsWarning(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
|
||||
t.Fatalf("branch findings = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
|
||||
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||
runGit(t, repo, "add", "docs.md")
|
||||
runGit(t, repo, "commit", "-m", "docs")
|
||||
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||
runGit(t, repo, "checkout", "--detach", head)
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
BranchName: "bot/public-doc-update",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||
}
|
||||
|
||||
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
|
||||
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||
runGit(t, repo, "add", "docs.md")
|
||||
runGit(t, repo, "commit", "-m", "docs")
|
||||
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||
runGit(t, repo, "checkout", "--detach", head)
|
||||
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||
}
|
||||
|
||||
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
|
||||
runGit(t, repo, "add", "docs/auth.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
|
||||
runGit(t, repo, "add", "docs/auth.md")
|
||||
runGit(t, repo, "commit", "-m", "add auth docs")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_bearer_header" {
|
||||
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
|
||||
t.Fatalf("changed-line attribution = %#v", item)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing bearer finding: %#v", got)
|
||||
}
|
||||
|
||||
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
|
||||
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
|
||||
got := appendUniqueFindings(base,
|
||||
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
|
||||
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func newGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
return repo
|
||||
}
|
||||
|
||||
func privateKeyBegin() string {
|
||||
return privateKeyBeginPrefix + privateKeyMarker + "\n"
|
||||
}
|
||||
|
||||
func privateKeyEnd() string {
|
||||
return privateKeyEndPrefix + privateKeyMarker + "\n"
|
||||
}
|
||||
|
||||
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
|
||||
t.Helper()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func requireFinding(t *testing.T, got []Finding, file, rule string) {
|
||||
t.Helper()
|
||||
for _, item := range got {
|
||||
if item.File == file && item.Rule == rule {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
|
||||
}
|
||||
|
||||
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"title":`)
|
||||
|
||||
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
|
||||
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
|
||||
t.Fatalf("Collect() error = %v, want metadata parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
if len(args) > 0 && args[0] == "commit" {
|
||||
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
}
|
||||
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
rules := findingRules(got)
|
||||
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
|
||||
t.Fatalf("comment audit findings = %#v", got)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.File != "issue_comment" {
|
||||
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/qualitygate/publiccontent/metadata.go
Normal file
45
internal/qualitygate/publiccontent/metadata.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func LoadMetadata(path string) (Metadata, error) {
|
||||
if path == "" {
|
||||
return Metadata{}, nil
|
||||
}
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Metadata{}, nil
|
||||
}
|
||||
var out Metadata
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanMetadata(m Metadata) []Finding {
|
||||
text := ""
|
||||
if m.Title != "" {
|
||||
text += "title: " + m.Title + "\n"
|
||||
}
|
||||
if m.Body != "" {
|
||||
text += "body:\n" + m.Body + "\n"
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
out := scanText("pull_request_metadata", "metadata", text, false)
|
||||
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
|
||||
return out
|
||||
}
|
||||
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
|
||||
|
||||
got, err := LoadMetadata(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMetadata() error = %v", err)
|
||||
}
|
||||
if got.Title != "public change" || got.Body == "" {
|
||||
t.Fatalf("metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
441
internal/qualitygate/publiccontent/rules.go
Normal file
441
internal/qualitygate/publiccontent/rules.go
Normal file
@@ -0,0 +1,441 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
|
||||
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
|
||||
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
|
||||
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
|
||||
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
|
||||
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
|
||||
)
|
||||
|
||||
func actionForRule(rule string) report.Action {
|
||||
switch rule {
|
||||
case "public_content_generic_credential",
|
||||
"public_content_private_key_block",
|
||||
"public_content_jwt_like_token",
|
||||
"public_content_bearer_header",
|
||||
"public_content_credential_url",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_reviewed_on_trailer",
|
||||
"public_content_provenance_marker",
|
||||
"public_content_detector_fingerprint",
|
||||
"public_content_harness_metadata",
|
||||
"public_content_ccm_harness_trailer":
|
||||
return report.ActionReject
|
||||
case "public_content_private_ipv4",
|
||||
"public_content_automation_branch":
|
||||
return report.ActionWarning
|
||||
default:
|
||||
return report.ActionWarning
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderValue(value string) bool {
|
||||
trimmed := strings.Trim(value, `"'`)
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if normalized == "" ||
|
||||
normalized == "=" ||
|
||||
percentWrappedPlaceholder(normalized) ||
|
||||
angleWrappedPlaceholder(normalized) ||
|
||||
urlWithAnglePlaceholder(normalized) ||
|
||||
isCredentialReferenceValue(trimmed) {
|
||||
return true
|
||||
}
|
||||
return namedPlaceholderValue(normalized)
|
||||
}
|
||||
|
||||
func namedPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
if len(value) < 4 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r != 'x' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func urlWithAnglePlaceholder(value string) bool {
|
||||
if !strings.Contains(value, "://") ||
|
||||
!strings.Contains(value, "<") ||
|
||||
!strings.Contains(value, ">") {
|
||||
return false
|
||||
}
|
||||
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
|
||||
}
|
||||
|
||||
func removeAnglePlaceholders(value string) string {
|
||||
var out strings.Builder
|
||||
for len(value) > 0 {
|
||||
start := strings.Index(value, "<")
|
||||
if start < 0 {
|
||||
out.WriteString(value)
|
||||
break
|
||||
}
|
||||
out.WriteString(value[:start])
|
||||
end := strings.Index(value[start+1:], ">")
|
||||
if end < 0 {
|
||||
out.WriteString(value[start:])
|
||||
break
|
||||
}
|
||||
value = value[start+end+2:]
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func urlRemainderLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{
|
||||
"secret",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"privatekey",
|
||||
"client_secret",
|
||||
"clientsecret",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||
}) {
|
||||
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func longCredentialSegment(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter || hasDigit
|
||||
}
|
||||
|
||||
func isCredentialReferenceValue(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.HasPrefix(normalized, "${{"):
|
||||
return githubExpressionReference(normalized)
|
||||
case strings.HasPrefix(normalized, "$("):
|
||||
return !commandSubstitutionLooksCredentialLike(normalized)
|
||||
case strings.HasPrefix(normalized, "process.env."):
|
||||
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
|
||||
case strings.HasPrefix(normalized, "${"):
|
||||
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
|
||||
case strings.HasPrefix(value, "$"):
|
||||
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func commandSubstitutionLooksCredentialLike(value string) bool {
|
||||
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
|
||||
return false
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
|
||||
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||
}) {
|
||||
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func githubExpressionReference(value string) bool {
|
||||
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
|
||||
return false
|
||||
}
|
||||
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
switch {
|
||||
case strings.HasPrefix(expr, "secrets."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
|
||||
case strings.HasPrefix(expr, "env."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
|
||||
case strings.HasPrefix(expr, "vars."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
|
||||
case expr == "github.token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func dottedReferenceIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(value, ".") {
|
||||
if !referenceIdentifier(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialReferenceIdentifier(value string) bool {
|
||||
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
|
||||
}
|
||||
|
||||
func referenceIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
case r == '_' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func angleWrappedPlaceholder(value string) bool {
|
||||
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||
return false
|
||||
}
|
||||
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
|
||||
}
|
||||
|
||||
func percentWrappedPlaceholder(value string) bool {
|
||||
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
|
||||
return false
|
||||
}
|
||||
inner := strings.Trim(value, "%")
|
||||
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
|
||||
}
|
||||
|
||||
func delimitedPlaceholderIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anglePlaceholderIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
if credentialShapedIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
switch value {
|
||||
case "token",
|
||||
"id",
|
||||
"userid",
|
||||
"openid",
|
||||
"key",
|
||||
"secret",
|
||||
"password",
|
||||
"api-key",
|
||||
"user-id",
|
||||
"open-id",
|
||||
"client-secret",
|
||||
"access-token",
|
||||
"refresh-token",
|
||||
"auth-token",
|
||||
"bearer-token",
|
||||
"session-token",
|
||||
"service-token":
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
case "wiki_token",
|
||||
"folder_token",
|
||||
"obj_token",
|
||||
"spreadsheet_token",
|
||||
"file_token",
|
||||
"doc_token",
|
||||
"node_token",
|
||||
"parent_node_token",
|
||||
"origin_node_token",
|
||||
"drive_route_token":
|
||||
return true
|
||||
default:
|
||||
return minuteTokenFixturePlaceholder(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func minuteTokenFixturePlaceholder(value string) bool {
|
||||
if value == "minute_no_meta" {
|
||||
return true
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(value, "minute_")
|
||||
if !ok || suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func provenanceMarker(line string) bool {
|
||||
normalized := strings.ToLower(line)
|
||||
markers := []string{
|
||||
"generat" + "ed by tool",
|
||||
"creat" + "ed by tool",
|
||||
"generat" + "ed by automation",
|
||||
"creat" + "ed by automation",
|
||||
"machine-" + "generated",
|
||||
"generated with automated",
|
||||
"generated with automation",
|
||||
"🤖 generated",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(normalized, "co-authored-by:") &&
|
||||
(strings.Contains(normalized, "<bot@") ||
|
||||
strings.Contains(normalized, " bot@") ||
|
||||
strings.Contains(normalized, "[bot]") ||
|
||||
strings.Contains(normalized, "automation") ||
|
||||
strings.Contains(normalized, "automated-code-assistant")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detector fingerprint checks are intentionally scoped to public rule/config
|
||||
// files. They do not try to hide this package's implementation; they prevent
|
||||
// publishing reusable detector identifiers in external-facing rule bundles.
|
||||
func isDetectorRuleFile(path string) bool {
|
||||
normalized := filepath.ToSlash(path)
|
||||
base := filepath.Base(normalized)
|
||||
return base == ".gitleaks.toml" ||
|
||||
strings.Contains(normalized, "public-rules/") ||
|
||||
strings.Contains(normalized, "public_rules/")
|
||||
}
|
||||
|
||||
func detectorFingerprint(line string) bool {
|
||||
normalized := strings.ToLower(line)
|
||||
fingerprints := []string{
|
||||
strings.Join([]string{"public", "content", "leakage"}, "-"),
|
||||
strings.Join([]string{"public", "content", "detector"}, "-"),
|
||||
"publiccontent",
|
||||
}
|
||||
for _, fingerprint := range fingerprints {
|
||||
if strings.Contains(normalized, fingerprint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactCredentialURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.User == nil {
|
||||
return "<credential-url>"
|
||||
}
|
||||
u.User = url.UserPassword("<user>", "<redacted>")
|
||||
return u.String()
|
||||
}
|
||||
797
internal/qualitygate/publiccontent/scan.go
Normal file
797
internal/qualitygate/publiccontent/scan.go
Normal file
@@ -0,0 +1,797 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyBeginPrefix = "-----" + "BEGIN "
|
||||
privateKeyEndPrefix = "-----" + "END "
|
||||
privateKeyMarker = "PRIVATE " + "KEY-----"
|
||||
)
|
||||
|
||||
func ScanFile(path string, data []byte) []Finding {
|
||||
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
func semanticCandidate(file, source, text string, line int) []Finding {
|
||||
excerpt := redactedSemanticExcerpt(text)
|
||||
if excerpt == "" {
|
||||
return nil
|
||||
}
|
||||
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
|
||||
}
|
||||
|
||||
func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
var out []Finding
|
||||
lines := strings.Split(text, "\n")
|
||||
inPrivateKey := false
|
||||
privateKeyLine := 0
|
||||
for i, line := range lines {
|
||||
lineNo := i + 1
|
||||
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = true
|
||||
privateKeyLine = lineNo
|
||||
}
|
||||
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
continue
|
||||
}
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if isSchemaDottedIdentifier(line, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||
}
|
||||
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
|
||||
}
|
||||
switch {
|
||||
case changeIDTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
|
||||
case reviewedOnTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
|
||||
case ccmHarnessTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
|
||||
}
|
||||
if provenanceMarker(line) {
|
||||
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
|
||||
}
|
||||
if strings.Contains(line, "/tmp/harness-agent") {
|
||||
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
|
||||
}
|
||||
if detectorFile && detectorFingerprint(line) {
|
||||
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
key, _, ok := normalizedCredentialAssignment(match)
|
||||
return key, ok
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignment(match string) (string, string, bool) {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", "", false
|
||||
}
|
||||
submatches := credentialAssignmentRE.FindStringSubmatch(match)
|
||||
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
|
||||
}
|
||||
|
||||
func normalizedCredentialKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
var out []rune
|
||||
var prev rune
|
||||
for i, r := range key {
|
||||
if r == '-' {
|
||||
r = '_'
|
||||
}
|
||||
if i > 0 && isCredentialKeyBoundary(prev, r) {
|
||||
out = append(out, '_')
|
||||
}
|
||||
out = append(out, unicode.ToLower(r))
|
||||
prev = r
|
||||
}
|
||||
key = string(out)
|
||||
key = strings.ReplaceAll(key, "-", "_")
|
||||
return key
|
||||
}
|
||||
|
||||
func isCredentialKeyBoundary(prev, current rune) bool {
|
||||
if prev == '_' || current == '_' {
|
||||
return false
|
||||
}
|
||||
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
|
||||
}
|
||||
|
||||
func isBenignTokenField(key string) bool {
|
||||
if isTokenMetricField(key) ||
|
||||
isTokenMetadataField(key) ||
|
||||
isResourceTokenField(key) ||
|
||||
isPaginationOrSyncTokenField(key) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTokenMetricField(key string) bool {
|
||||
switch key {
|
||||
case "tokenizer",
|
||||
"token_count",
|
||||
"tokens",
|
||||
"max_tokens",
|
||||
"completion_tokens",
|
||||
"prompt_tokens":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTokenMetadataField(key string) bool {
|
||||
switch key {
|
||||
case "access_token_expires_in",
|
||||
"refresh_token_expires_in",
|
||||
"token_expires_in",
|
||||
"token_status",
|
||||
"token_type",
|
||||
"token_url",
|
||||
"token_endpoint",
|
||||
"token_format",
|
||||
"secret_name":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPaginationOrSyncTokenField(key string) bool {
|
||||
switch key {
|
||||
case "page_token",
|
||||
"next_page_token",
|
||||
"sync_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResourceTokenField(key string) bool {
|
||||
if !strings.HasSuffix(key, "_token") {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSuffix(key, "_token")
|
||||
switch prefix {
|
||||
case "app",
|
||||
"base",
|
||||
"board",
|
||||
"doc",
|
||||
"drive_route",
|
||||
"file",
|
||||
"folder",
|
||||
"host_node",
|
||||
"minute",
|
||||
"node",
|
||||
"obj",
|
||||
"origin_node",
|
||||
"parent",
|
||||
"parent_file",
|
||||
"parent_node",
|
||||
"share",
|
||||
"spreadsheet",
|
||||
"target",
|
||||
"wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
||||
switch {
|
||||
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
|
||||
return true
|
||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||
return true
|
||||
case tokenLikePlaceholderKey(key):
|
||||
return tokenLikePlaceholderValue(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderKey(key string) bool {
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
normalized == "token" ||
|
||||
strings.Contains(normalized, "...") ||
|
||||
strings.Contains(normalized, "xxx") ||
|
||||
strings.Contains(normalized, "_or_") ||
|
||||
strings.HasSuffix(normalized, "_token") ||
|
||||
strings.HasPrefix(normalized, ".")
|
||||
}
|
||||
|
||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func uuidStringPlaceholderValue(value string) bool {
|
||||
normalized := strings.Trim(value, `"'`)
|
||||
parts := strings.Split(normalized, "-")
|
||||
if len(parts) != 5 {
|
||||
return false
|
||||
}
|
||||
for i, part := range parts {
|
||||
want := []int{8, 4, 4, 4, 12}[i]
|
||||
if len(part) != want {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if (r >= '0' && r <= '9') ||
|
||||
(r >= 'a' && r <= 'f') ||
|
||||
(r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func numericStringPlaceholderValue(value string) bool {
|
||||
normalized := strings.Trim(value, `"'`)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range normalized {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".py":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func quotedLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
return len(normalized) >= 2 &&
|
||||
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
|
||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||
}
|
||||
|
||||
func codeReferenceExpression(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
|
||||
if strings.Contains(value, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||
}
|
||||
|
||||
func codeIdentifier(value string) bool {
|
||||
for i, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r == '_' && i > 0:
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialNameFragment(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSchemaDottedIdentifier(line, match string) bool {
|
||||
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isWebhookCredentialKey(key string) bool {
|
||||
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
||||
}
|
||||
|
||||
func webhookAssignmentValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
|
||||
return false
|
||||
}
|
||||
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
|
||||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
|
||||
}
|
||||
|
||||
func isExplicitCredentialKey(key string) bool {
|
||||
compact := strings.ReplaceAll(key, "_", "")
|
||||
switch compact {
|
||||
case "token",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"clientsecret",
|
||||
"password",
|
||||
"passwd":
|
||||
return true
|
||||
}
|
||||
for _, phrase := range []string{
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"bottoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"clientsecret",
|
||||
"secretkey",
|
||||
} {
|
||||
if strings.Contains(compact, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parts := credentialKeyParts(key)
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "key"},
|
||||
{"access", "key"},
|
||||
{"private", "key"},
|
||||
{"api", "secret"},
|
||||
{"client", "secret"},
|
||||
{"secret", "key"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, part := range parts {
|
||||
switch part {
|
||||
case "token", "secret", "password", "passwd":
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"token",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"bottoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"clientsecret",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"password",
|
||||
"passwd",
|
||||
} {
|
||||
if strings.HasSuffix(compact, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"_access_token",
|
||||
"_refresh_token",
|
||||
"_auth_token",
|
||||
"_bearer_token",
|
||||
"_session_token",
|
||||
"_service_token",
|
||||
"_api_key",
|
||||
"_access_key",
|
||||
"_private_key",
|
||||
"_api_secret",
|
||||
"_client_secret",
|
||||
"_secret",
|
||||
"_secret_key",
|
||||
"_password",
|
||||
"_passwd",
|
||||
} {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialKeyParts(key string) []string {
|
||||
var parts []string
|
||||
for _, part := range strings.Split(key, "_") {
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
|
||||
for i := 0; i+1 < len(parts); i++ {
|
||||
if parts[i] == first && parts[i+1] == second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialAssignmentValue(match []string) string {
|
||||
for _, value := range match[1:] {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, password, ok := strings.Cut(userInfo, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
normalized := strings.ToLower(password)
|
||||
decoded := strings.ReplaceAll(normalized, "%3c", "<")
|
||||
decoded = strings.ReplaceAll(decoded, "%3e", ">")
|
||||
switch decoded {
|
||||
case "placeholder", "redacted", "<redacted>", "xxxx":
|
||||
return true
|
||||
}
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := raw[schemeIdx+len("://"):]
|
||||
atIdx := strings.Index(rest, "@")
|
||||
if atIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:atIdx], true
|
||||
}
|
||||
|
||||
func newFinding(rule, file string, line int, source, excerpt string) Finding {
|
||||
return Finding{
|
||||
Rule: rule,
|
||||
Action: actionForRule(rule),
|
||||
File: file,
|
||||
Line: line,
|
||||
Source: source,
|
||||
Excerpt: excerpt,
|
||||
Message: messageForRule(rule),
|
||||
Suggestion: suggestionForRule(rule),
|
||||
}
|
||||
}
|
||||
|
||||
func messageForRule(rule string) string {
|
||||
switch rule {
|
||||
case "public_content_generic_credential":
|
||||
return "public contribution contains a generic credential assignment"
|
||||
case "public_content_private_key_block":
|
||||
return "public contribution contains a private key block"
|
||||
case "public_content_jwt_like_token":
|
||||
return "public contribution contains a JWT-like token"
|
||||
case "public_content_bearer_header":
|
||||
return "public contribution contains an Authorization bearer token"
|
||||
case "public_content_credential_url":
|
||||
return "public contribution contains credentials embedded in a URL"
|
||||
case "public_content_private_ipv4":
|
||||
return "public contribution contains a private-network IP address"
|
||||
case "public_content_automation_branch":
|
||||
return "public contribution uses an automation-shaped branch name"
|
||||
case "public_content_change_id_trailer":
|
||||
return "public contribution contains a Change-Id trailer"
|
||||
case "public_content_reviewed_on_trailer":
|
||||
return "public contribution contains a Reviewed-on trailer"
|
||||
case "public_content_provenance_marker":
|
||||
return "public contribution contains a prohibited provenance marker"
|
||||
case "public_content_detector_fingerprint":
|
||||
return "public rule/config content exposes public detector fingerprints"
|
||||
case "public_content_harness_metadata":
|
||||
return "public contribution contains visible harness pipeline metadata"
|
||||
case "public_content_ccm_harness_trailer":
|
||||
return "public contribution contains a CCM-Harness trailer"
|
||||
case "public_content_semantic_candidate":
|
||||
return "public contribution contains text for semantic public content review"
|
||||
default:
|
||||
return "public contribution contains content that should not be published"
|
||||
}
|
||||
}
|
||||
|
||||
func suggestionForRule(rule string) string {
|
||||
switch actionForRule(rule) {
|
||||
case "REJECT":
|
||||
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
|
||||
default:
|
||||
return "remove private workflow metadata before publishing the public contribution"
|
||||
}
|
||||
}
|
||||
|
||||
func redactAssignment(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "<credential-assignment>"
|
||||
}
|
||||
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
func credentialAssignmentKey(match string) (string, bool) {
|
||||
idx := -1
|
||||
for _, sep := range []string{":", "="} {
|
||||
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
|
||||
idx = candidate
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return match[:idx], true
|
||||
}
|
||||
|
||||
func redactToken(_ string) string {
|
||||
return "<jwt-like-token>"
|
||||
}
|
||||
|
||||
func redactedSemanticExcerpt(text string) string {
|
||||
normalized := strings.Join(strings.Fields(text), " ")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
signals := semanticSignals(normalized)
|
||||
if len(signals) == 0 {
|
||||
return ""
|
||||
}
|
||||
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
|
||||
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
|
||||
}
|
||||
|
||||
func semanticSignals(normalized string) []string {
|
||||
lower := strings.ToLower(normalized)
|
||||
var signals []string
|
||||
add := func(signal string) {
|
||||
for _, existing := range signals {
|
||||
if existing == signal {
|
||||
return
|
||||
}
|
||||
}
|
||||
signals = append(signals, signal)
|
||||
}
|
||||
|
||||
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
|
||||
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
|
||||
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
|
||||
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
|
||||
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
|
||||
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
|
||||
|
||||
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
|
||||
add("private_scope")
|
||||
add("request_metadata")
|
||||
add("trust_boundary_detail")
|
||||
}
|
||||
if hasRoadmap && (hasPrivateScope || hasTiming) {
|
||||
add("roadmap_detail")
|
||||
if hasPrivateScope {
|
||||
add("private_scope")
|
||||
}
|
||||
if hasTiming {
|
||||
add("roadmap_timing")
|
||||
}
|
||||
}
|
||||
if hasPrivateScope && hasImplementation && hasTrustBoundary {
|
||||
add("private_scope")
|
||||
add("implementation_detail")
|
||||
add("trust_boundary_detail")
|
||||
}
|
||||
|
||||
return signals
|
||||
}
|
||||
|
||||
func sanitizeSemanticExcerpt(text string) string {
|
||||
text = redactPrivateKeyBlocks(text)
|
||||
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
|
||||
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
||||
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
||||
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
||||
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
|
||||
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func redactPrivateKeyBlocks(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
var out []string
|
||||
inPrivateKey := false
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, "<private-key-block>")
|
||||
inPrivateKey = true
|
||||
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inPrivateKey {
|
||||
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func sanitizeCredentialAssignment(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "<credential-assignment>"
|
||||
}
|
||||
return strings.TrimSpace(key) + "=<redacted>"
|
||||
}
|
||||
|
||||
func sanitizeCredentialURL(raw string) string {
|
||||
redacted := redactCredentialURL(raw)
|
||||
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
|
||||
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func truncateRunes(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
1056
internal/qualitygate/publiccontent/scan_test.go
Normal file
1056
internal/qualitygate/publiccontent/scan_test.go
Normal file
File diff suppressed because it is too large
Load Diff
30
internal/qualitygate/publiccontent/types.go
Normal file
30
internal/qualitygate/publiccontent/types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "github.com/larksuite/cli/internal/qualitygate/report"
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
ChangedFrom string
|
||||
MetadataPath string
|
||||
BranchName string
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
type Finding struct {
|
||||
Rule string
|
||||
Action report.Action
|
||||
File string
|
||||
Line int
|
||||
Source string
|
||||
Excerpt string
|
||||
Message string
|
||||
Suggestion string
|
||||
}
|
||||
@@ -174,8 +174,9 @@ type materializedExample struct {
|
||||
}
|
||||
|
||||
type placeholderContext struct {
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagDefault string
|
||||
}
|
||||
|
||||
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
|
||||
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
|
||||
ctx := placeholderContext{FlagName: name}
|
||||
if flag != nil {
|
||||
ctx.FlagUsage = flag.Usage
|
||||
ctx.FlagDefault = flag.DefValue
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeValueFromPlaceholderName(name); ok {
|
||||
return value, true
|
||||
}
|
||||
if isGenericPlaceholderName(name) {
|
||||
return fakeValueFromUsageHint(ctx.FlagUsage)
|
||||
return fakeValueFromContextHint(ctx)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
return "file_test123", true
|
||||
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
|
||||
return "file_test123", true
|
||||
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
|
||||
return "fld_test123", true
|
||||
case hasPlaceholderToken(tokens, "image", "img"):
|
||||
return "img_test123", true
|
||||
case hasPlaceholderToken(tokens, "app"):
|
||||
return "app_test123", true
|
||||
case hasPlaceholderToken(tokens, "draft"):
|
||||
return "draft_test123", true
|
||||
case hasPlaceholderToken(tokens, "label"):
|
||||
return "label_test123", true
|
||||
case hasPlaceholderToken(tokens, "share"):
|
||||
return "share_test123", true
|
||||
case hasPlaceholderToken(tokens, "doc", "document"):
|
||||
return "doc_test123", true
|
||||
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
|
||||
return "shtcn_test123", true
|
||||
case hasPlaceholderToken(tokens, "base"):
|
||||
return "base_test123", true
|
||||
case hasPlaceholderToken(tokens, "space"):
|
||||
return "space_test123", true
|
||||
case hasPlaceholderToken(tokens, "table"):
|
||||
return "tbl_test123", true
|
||||
case hasPlaceholderToken(tokens, "view"):
|
||||
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func fakeValueFromUsageHint(usage string) (string, bool) {
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
|
||||
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
|
||||
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
|
||||
if len(match) != 2 || !knownTokenPrefix(match[1]) {
|
||||
return "", false
|
||||
}
|
||||
return match[1] + "_test123", true
|
||||
}
|
||||
|
||||
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
|
||||
nameTokens := placeholderTokenSet(name)
|
||||
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
|
||||
flagTokens := placeholderTokenSet(flagName)
|
||||
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
|
||||
return "", false
|
||||
}
|
||||
usage := strings.ToLower(ctx.FlagUsage)
|
||||
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
|
||||
return "https://example.feishu.cn/docx/doc_test123", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
|
||||
nameTokens := placeholderTokenSet(name)
|
||||
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
|
||||
flagTokens := placeholderTokenSet(flagName)
|
||||
usage := strings.ToLower(ctx.FlagUsage)
|
||||
|
||||
switch {
|
||||
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
|
||||
return "400000000001", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
|
||||
return "400000000001", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
|
||||
return "123456789", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
|
||||
return "123456789", true
|
||||
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
|
||||
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
|
||||
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
|
||||
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func numericPlaceholderName(tokens map[string]bool) bool {
|
||||
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
|
||||
return false
|
||||
}
|
||||
return hasPlaceholderToken(tokens,
|
||||
"amount", "count", "depth", "height", "index", "length", "limit", "max",
|
||||
"number", "revision", "size", "width",
|
||||
)
|
||||
}
|
||||
|
||||
func numericUsageHint(usage string) bool {
|
||||
if usage == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(usage, "positive integer") ||
|
||||
strings.Contains(usage, "decimal integer") ||
|
||||
strings.Contains(usage, "number of ") ||
|
||||
strings.Contains(usage, "(number)")
|
||||
}
|
||||
|
||||
func defaultPositiveInteger(raw, fallback string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func knownTokenPrefix(prefix string) bool {
|
||||
switch prefix {
|
||||
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
|
||||
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
|
||||
return tokens[first] && tokens[second]
|
||||
}
|
||||
|
||||
func hasUnresolvedDryRunPlaceholder(value string) bool {
|
||||
if skillscan.HasPlaceholder(value) {
|
||||
return true
|
||||
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return nil, fmt.Errorf("not a lark-cli command")
|
||||
}
|
||||
argv = truncateShellTail(argv)
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
hasDryRunArg := false
|
||||
dryRunEnabled := false
|
||||
for _, arg := range argv[1:] {
|
||||
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return append(argv[1:], "--dry-run"), nil
|
||||
}
|
||||
|
||||
func forceDryRunJSONFormat(argv []string) []string {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
if arg == "--format" {
|
||||
if i+1 < len(argv) && argv[i+1] == "pretty" {
|
||||
argv[i+1] = "json"
|
||||
}
|
||||
return argv
|
||||
}
|
||||
if arg == "--format=pretty" {
|
||||
argv[i] = "--format=json"
|
||||
return argv
|
||||
}
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
func truncateShellTail(argv []string) []string {
|
||||
for i, arg := range argv {
|
||||
if i == 0 {
|
||||
|
||||
@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "vc +meeting-events",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
|
||||
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
|
||||
SourceFile: "skills/lark-vc-agent/SKILL.md",
|
||||
Line: 120,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "api GET",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "params", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
|
||||
SourceFile: "skills/lark-demo/SKILL.md",
|
||||
Line: 20,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "drive +inspect",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
|
||||
{Name: "format", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli drive +inspect --url '<url>' --format json",
|
||||
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
|
||||
Line: 15,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "wiki +node-list",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
|
||||
{Name: "page-token", TakesValue: true, Usage: "page token"},
|
||||
{Name: "format", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
|
||||
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
|
||||
Line: 24,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "mail user_mailbox.drafts send",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "params", TakesValue: true},
|
||||
{Name: "data", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
|
||||
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
|
||||
Line: 172,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +chat-messages-list",
|
||||
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"lark-cli mail +watch --format data --dry-run",
|
||||
"lark-cli export +events --format=ndjson --dry-run",
|
||||
"lark-cli docs +fetch --format table",
|
||||
} {
|
||||
got, err := appendDryRunArg(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
|
||||
}
|
||||
for _, arg := range got {
|
||||
if arg == "--format=json" {
|
||||
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
|
||||
}
|
||||
}
|
||||
for i, arg := range got {
|
||||
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
|
||||
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
|
||||
if err != nil {
|
||||
|
||||
@@ -15,18 +15,20 @@ import (
|
||||
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/qualitygate/skillscan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
PublicContentMetadataPath string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
|
||||
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
|
||||
if opts.ChangedFrom != "" {
|
||||
diags = append(diags, errorDiags...)
|
||||
}
|
||||
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
|
||||
Repo: opts.Repo,
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
MetadataPath: opts.PublicContentMetadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, facts.Facts{}, err
|
||||
}
|
||||
diags = append(diags, publicContentDiagnostics(publicContent)...)
|
||||
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
|
||||
|
||||
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
|
||||
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
|
||||
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
|
||||
}
|
||||
|
||||
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]report.Diagnostic, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Rule == "public_content_semantic_candidate" {
|
||||
continue
|
||||
}
|
||||
out = append(out, report.Diagnostic{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]facts.PublicContentFact, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, facts.PublicContentFact{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Source: item.Source,
|
||||
Excerpt: item.Excerpt,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
|
||||
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
|
||||
}
|
||||
|
||||
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
|
||||
if strings.HasPrefix(diag.Rule, "public_content_") {
|
||||
return true
|
||||
}
|
||||
file := normalizeDiagnosticFile(repo, diag.File)
|
||||
if file != "" && changedFiles[file] {
|
||||
return true
|
||||
|
||||
@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(repo, "command-manifest.json")
|
||||
indexPath := filepath.Join(repo, "command-index.json")
|
||||
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
|
||||
Path: "docs +fetch",
|
||||
CanonicalPath: "docs +fetch",
|
||||
Domain: "docs",
|
||||
Source: manifest.SourceShortcut,
|
||||
}}}
|
||||
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
|
||||
idx.Commands = append(idx.Commands, manifest.Command{
|
||||
Path: "drive files get",
|
||||
CanonicalPath: "drive files get",
|
||||
Domain: "drive",
|
||||
Source: manifest.SourceService,
|
||||
Generated: true,
|
||||
Runnable: true,
|
||||
})
|
||||
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
diags, gotFacts, err := Run(context.Background(), Options{
|
||||
Repo: repo,
|
||||
CLIBin: "./lark-cli",
|
||||
ChangedFrom: "HEAD~1",
|
||||
ManifestPath: manifestPath,
|
||||
CommandIndexPath: indexPath,
|
||||
PublicContentMetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
actions := map[string]report.Action{}
|
||||
for _, diag := range diags {
|
||||
actions[diag.Rule] = diag.Action
|
||||
}
|
||||
if actions["public_content_generic_credential"] != report.ActionReject {
|
||||
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
|
||||
}
|
||||
if actions["public_content_change_id_trailer"] != report.ActionReject {
|
||||
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
|
||||
}
|
||||
if actions["public_content_semantic_candidate"] != "" {
|
||||
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
|
||||
}
|
||||
factRules := map[string]bool{}
|
||||
for _, item := range gotFacts.PublicContent {
|
||||
factRules[item.Rule] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"public_content_generic_credential",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_semantic_candidate",
|
||||
} {
|
||||
if !factRules[want] {
|
||||
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
|
||||
}
|
||||
}
|
||||
if len(gotFacts.PublicContent) < 3 {
|
||||
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
|
||||
"properties": map[string]any{
|
||||
"category": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
},
|
||||
"severity": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
|
||||
|
||||
func Decide(f facts.Facts, r Review, p Policy) Decision {
|
||||
return DecideWithWaivers(f, r, p, Waivers{})
|
||||
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
|
||||
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
|
||||
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
|
||||
}, ":")
|
||||
case "public_content":
|
||||
item := f.PublicContent[idx]
|
||||
return strings.Join([]string{
|
||||
"public_content",
|
||||
"rule:" + item.Rule,
|
||||
"action:" + string(item.Action),
|
||||
"file:" + item.File,
|
||||
"line:" + strconv.Itoa(item.Line),
|
||||
"source:" + item.Source,
|
||||
}, ":")
|
||||
default:
|
||||
return "ref:" + ev
|
||||
}
|
||||
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
|
||||
|
||||
func allowedCategory(category string) bool {
|
||||
switch category {
|
||||
case "error_hint", "default_output", "naming", "skill_quality":
|
||||
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
|
||||
}
|
||||
skill := f.Skills[idx]
|
||||
return skill.ReferencesInvalidCommand
|
||||
case "public_content_leakage":
|
||||
if kind != "public_content" {
|
||||
return false
|
||||
}
|
||||
item := f.PublicContent[idx]
|
||||
return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
|
||||
return idx < len(f.Errors)
|
||||
case "outputs":
|
||||
return idx < len(f.Outputs)
|
||||
case "public_content":
|
||||
return idx < len(f.PublicContent)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
|
||||
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
|
||||
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
|
||||
PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
category string
|
||||
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
{"default_output", "facts.outputs[0]"},
|
||||
{"naming", "facts.commands[0]"},
|
||||
{"skill_quality", "facts.skills[0]"},
|
||||
{"public_content_leakage", "facts.public_content[0]"},
|
||||
} {
|
||||
t.Run(tc.category, func(t *testing.T) {
|
||||
r := Review{Findings: []Finding{{
|
||||
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
Action: "WARNING",
|
||||
File: "docs/network.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "minor",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "pri" + "vate network address appears in public docs",
|
||||
SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
|
||||
}}}
|
||||
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
|
||||
t.Fatalf("public content warning should not become a blocker: %#v", got)
|
||||
}
|
||||
if got.Warnings[0].ReviewAction != ReviewActionObserve {
|
||||
t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: "WARNING",
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "major",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "semantic review found pri" + "vate rollout detail",
|
||||
SuggestedAction: "remove pri" + "vate rollout detail from public docs",
|
||||
}}}
|
||||
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 1 {
|
||||
t.Fatalf("semantic candidate should remain blockable, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
|
||||
@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
"Use only the provided JSON view.",
|
||||
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
|
||||
"Use fact_ref values exactly when writing finding evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
"facts.examples and facts.skill_quality entries are context only.",
|
||||
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
|
||||
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
|
||||
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
|
||||
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
|
||||
"Review public content leakage findings and semantic candidates without private dictionaries.",
|
||||
"Do not reveal internal rule lists when explaining public content leakage.",
|
||||
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
|
||||
"Report each distinct issue as a separate finding.",
|
||||
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
|
||||
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",
|
||||
|
||||
@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
|
||||
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
|
||||
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
|
||||
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Review public content leakage findings and semantic candidates without private dictionaries.",
|
||||
"Do not reveal internal rule lists when explaining public content leakage.",
|
||||
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
|
||||
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
"facts.examples and facts.skill_quality entries are context only.",
|
||||
"Report each distinct issue as a separate finding.",
|
||||
|
||||
@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
SchemaVersion: 1,
|
||||
DefaultEnforcement: "observe",
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
RolloutGroups: []RolloutGroup{{
|
||||
ID: "all",
|
||||
Enforcement: "blocking",
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
Owner: "test",
|
||||
Reason: "default in-memory policy",
|
||||
}},
|
||||
|
||||
@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
|
||||
Source: item.Source,
|
||||
CommandPath: item.Command,
|
||||
}, true
|
||||
case "public_content":
|
||||
item := f.PublicContent[idx]
|
||||
return FactScope{
|
||||
FactKind: "public_content",
|
||||
Changed: true,
|
||||
Source: item.Source,
|
||||
SourceFile: item.File,
|
||||
Line: item.Line,
|
||||
}, true
|
||||
default:
|
||||
return FactScope{}, false
|
||||
}
|
||||
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
|
||||
|
||||
func allowedFactKind(kind string) bool {
|
||||
switch kind {
|
||||
case "skill", "command", "error", "output":
|
||||
case "skill", "command", "error", "output", "public_content":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperUsesPublicContentEvidence(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: "REJECT",
|
||||
File: "docs/public.md",
|
||||
Line: 12,
|
||||
Source: "metadata",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "critical",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "public content finding needs review",
|
||||
SuggestedAction: "remove the sensitive public content",
|
||||
}}}
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" {
|
||||
t.Fatalf("expected public content blocker, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperAppliesSharedWaiverID(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
|
||||
@@ -13,27 +13,29 @@ import (
|
||||
)
|
||||
|
||||
type InputView struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChangedSummary ChangedSummary `json:"changed_summary"`
|
||||
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
|
||||
Commands []CommandInput `json:"commands,omitempty"`
|
||||
Skills []SkillInput `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorInput `json:"errors,omitempty"`
|
||||
Outputs []OutputInput `json:"outputs,omitempty"`
|
||||
Examples []ExampleInput `json:"examples,omitempty"`
|
||||
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChangedSummary ChangedSummary `json:"changed_summary"`
|
||||
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
|
||||
Commands []CommandInput `json:"commands,omitempty"`
|
||||
Skills []SkillInput `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorInput `json:"errors,omitempty"`
|
||||
Outputs []OutputInput `json:"outputs,omitempty"`
|
||||
Examples []ExampleInput `json:"examples,omitempty"`
|
||||
PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"`
|
||||
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type ChangedSummary struct {
|
||||
Commands int `json:"commands,omitempty"`
|
||||
Skills int `json:"skills,omitempty"`
|
||||
SkillQuality int `json:"skill_quality,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Outputs int `json:"outputs,omitempty"`
|
||||
Examples int `json:"examples,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
Commands int `json:"commands,omitempty"`
|
||||
Skills int `json:"skills,omitempty"`
|
||||
SkillQuality int `json:"skill_quality,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Outputs int `json:"outputs,omitempty"`
|
||||
Examples int `json:"examples,omitempty"`
|
||||
PublicContent int `json:"public_content,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
type RuleSummaryItem struct {
|
||||
@@ -86,6 +88,22 @@ type ExampleInput struct {
|
||||
facts.CommandExample
|
||||
}
|
||||
|
||||
type PublicContentInput struct {
|
||||
FactRef string `json:"fact_ref"`
|
||||
facts.PublicContentFact
|
||||
}
|
||||
|
||||
func (v InputView) HasReviewableFacts() bool {
|
||||
return len(v.Commands) > 0 ||
|
||||
len(v.Skills) > 0 ||
|
||||
len(v.SkillQuality) > 0 ||
|
||||
len(v.Errors) > 0 ||
|
||||
len(v.Outputs) > 0 ||
|
||||
len(v.Examples) > 0 ||
|
||||
len(v.PublicContentLeakage) > 0 ||
|
||||
len(v.Diagnostics) > 0
|
||||
}
|
||||
|
||||
func BuildInputView(f facts.Facts) InputView {
|
||||
selected := newInputSelection(f)
|
||||
selected.addChangedReviewCandidates()
|
||||
@@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView {
|
||||
}
|
||||
|
||||
return InputView{
|
||||
SchemaVersion: f.SchemaVersion,
|
||||
ChangedSummary: changedSummary(f),
|
||||
RuleSummary: ruleSummary(f.Diagnostics),
|
||||
Commands: selected.commandInputs(),
|
||||
Skills: selected.skillInputs(),
|
||||
SkillQuality: selected.skillQualityInputs(),
|
||||
Errors: selected.errorInputs(),
|
||||
Outputs: selected.outputInputs(),
|
||||
Examples: selected.exampleInputs(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
SchemaVersion: f.SchemaVersion,
|
||||
ChangedSummary: changedSummary(f),
|
||||
RuleSummary: ruleSummary(f.Diagnostics),
|
||||
Commands: selected.commandInputs(),
|
||||
Skills: selected.skillInputs(),
|
||||
SkillQuality: selected.skillQualityInputs(),
|
||||
Errors: selected.errorInputs(),
|
||||
Outputs: selected.outputInputs(),
|
||||
Examples: selected.exampleInputs(),
|
||||
PublicContentLeakage: selected.publicContentInputs(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() {
|
||||
s.outputs[i] = true
|
||||
}
|
||||
}
|
||||
for i, item := range s.f.PublicContent {
|
||||
if publicContentReviewCandidate(item) {
|
||||
s.publicContent[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandReviewCandidate(cmd facts.CommandFact) bool {
|
||||
@@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func publicContentReviewCandidate(item facts.PublicContentFact) bool {
|
||||
return item.Rule == "public_content_semantic_candidate"
|
||||
}
|
||||
|
||||
type inputSelection struct {
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []bool
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []bool
|
||||
publicContent []bool
|
||||
}
|
||||
|
||||
func newInputSelection(f facts.Facts) *inputSelection {
|
||||
return &inputSelection{
|
||||
f: f,
|
||||
commands: make([]bool, len(f.Commands)),
|
||||
skills: make([]bool, len(f.Skills)),
|
||||
skillQuality: make([]bool, len(f.SkillQuality)),
|
||||
errors: make([]bool, len(f.Errors)),
|
||||
outputs: make([]bool, len(f.Outputs)),
|
||||
examples: make([]bool, len(f.Examples)),
|
||||
f: f,
|
||||
commands: make([]bool, len(f.Commands)),
|
||||
skills: make([]bool, len(f.Skills)),
|
||||
skillQuality: make([]bool, len(f.SkillQuality)),
|
||||
errors: make([]bool, len(f.Errors)),
|
||||
outputs: make([]bool, len(f.Outputs)),
|
||||
examples: make([]bool, len(f.Examples)),
|
||||
publicContent: make([]bool, len(f.PublicContent)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
s.addDiagnosticExamples(out, diag)
|
||||
case diag.Rule == "no_bare_helper_error":
|
||||
s.addDiagnosticErrors(out, diag)
|
||||
case strings.HasPrefix(diag.Rule, "public_content_"):
|
||||
s.addDiagnosticPublicContent(out, diag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, item := range s.f.PublicContent {
|
||||
if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) ||
|
||||
diag.Rule == item.Rule {
|
||||
out.publicContent[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
|
||||
if diag.Action == report.ActionReject {
|
||||
return true
|
||||
@@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) {
|
||||
mergeSelections(s.errors, other.errors)
|
||||
mergeSelections(s.outputs, other.outputs)
|
||||
mergeSelections(s.examples, other.examples)
|
||||
mergeSelections(s.publicContent, other.publicContent)
|
||||
}
|
||||
|
||||
func (s *inputSelection) intersects(other *inputSelection) bool {
|
||||
@@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool {
|
||||
selectionsIntersect(s.skillQuality, other.skillQuality) ||
|
||||
selectionsIntersect(s.errors, other.errors) ||
|
||||
selectionsIntersect(s.outputs, other.outputs) ||
|
||||
selectionsIntersect(s.examples, other.examples)
|
||||
selectionsIntersect(s.examples, other.examples) ||
|
||||
selectionsIntersect(s.publicContent, other.publicContent)
|
||||
}
|
||||
|
||||
func (s *inputSelection) commandInputs() []CommandInput {
|
||||
@@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *inputSelection) publicContentInputs() []PublicContentInput {
|
||||
out := make([]PublicContentInput, 0, countSelected(s.publicContent))
|
||||
for i, ok := range s.publicContent {
|
||||
if ok {
|
||||
out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func changedSummary(f facts.Facts) ChangedSummary {
|
||||
domains := map[string]bool{}
|
||||
sources := map[string]bool{}
|
||||
@@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary {
|
||||
addNonEmpty(domains, example.Domain)
|
||||
addNonEmpty(sources, example.Source)
|
||||
}
|
||||
for _, item := range f.PublicContent {
|
||||
out.PublicContent++
|
||||
addNonEmpty(sources, item.Source)
|
||||
}
|
||||
out.Domains = sortedViewSetKeys(domains)
|
||||
out.Sources = sortedViewSetKeys(sources)
|
||||
return out
|
||||
@@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool {
|
||||
strings.HasPrefix(rule, "default_output") ||
|
||||
strings.HasPrefix(rule, "skill_") ||
|
||||
strings.HasPrefix(rule, "example_dry_run") ||
|
||||
rule == "no_bare_helper_error"
|
||||
rule == "no_bare_helper_error" ||
|
||||
strings.HasPrefix(rule, "public_content_")
|
||||
}
|
||||
|
||||
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {
|
||||
|
||||
@@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewIncludesPublicContentLeakage(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: report.ActionReject,
|
||||
File: "docs/public.md",
|
||||
Line: 4,
|
||||
Excerpt: "api_key = <redacted>",
|
||||
Message: "generic credential assignment",
|
||||
}},
|
||||
Diagnostics: []facts.DiagnosticFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: report.ActionReject,
|
||||
File: "docs/public.md",
|
||||
Line: 4,
|
||||
Message: "generic credential assignment",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
|
||||
t.Fatalf("public content fact ref = %q", got)
|
||||
}
|
||||
if len(view.Diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: "public prose that needs semantic review",
|
||||
Message: "public contribution contains text for semantic public content review",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
|
||||
t.Fatalf("semantic candidate fact ref = %q", got)
|
||||
}
|
||||
if len(view.Diagnostics) != 0 {
|
||||
t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) {
|
||||
scopeText := "pri" + "vate rollout"
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token=<redacted>"`,
|
||||
Message: "public contribution contains text for semantic public content review",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=<redacted>") {
|
||||
t.Fatalf("semantic candidate excerpt missing from view: %q", got)
|
||||
}
|
||||
|
||||
messages := BuildPrompt(f)
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages len = %d, want 2", len(messages))
|
||||
}
|
||||
if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") {
|
||||
t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content)
|
||||
}
|
||||
if strings.Contains(messages[1].Content, "real-"+"secret-value") {
|
||||
t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/network.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: "192.168." + "0.10",
|
||||
Message: "public contribution contains a pri" + "vate-network IP address",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 0 {
|
||||
t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage)
|
||||
}
|
||||
if len(view.Diagnostics) != 0 {
|
||||
t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) {
|
||||
f := broadChangedFacts(434, 44)
|
||||
|
||||
|
||||
@@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) {
|
||||
if item.SourceFile == "" || item.Line == 0 {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind)
|
||||
}
|
||||
case "public_content":
|
||||
if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo)
|
||||
}
|
||||
case "command", "output":
|
||||
if item.CommandPath == "" {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind)
|
||||
|
||||
@@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) {
|
||||
|
||||
writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n")
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
|
||||
"public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n")
|
||||
w, diags, err = LoadWaivers(repo, now)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWaivers() error = %v", err)
|
||||
}
|
||||
if len(diags) != 0 || len(w.Items) != 2 {
|
||||
if len(diags) != 0 || len(w.Items) != 3 {
|
||||
t.Fatalf("LoadWaivers() = %#v %#v", w, diags)
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"bad columns": "one\ttoo-few\n",
|
||||
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
|
||||
"bad columns": "one\ttoo-few\n",
|
||||
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
writeSemanticFile(t, repo, "waivers.txt", body)
|
||||
|
||||
@@ -5609,6 +5609,21 @@
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.nickname:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.nickname:write",
|
||||
"final_score": "79.5982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:read",
|
||||
"final_score": "88.0587",
|
||||
|
||||
@@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
|
||||
repo: context.repo.repo,
|
||||
pull_number: target.pr,
|
||||
});
|
||||
if (pr.state !== "open") {
|
||||
core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.head.sha !== target.headSha) {
|
||||
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
|
||||
return false;
|
||||
|
||||
@@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish a summary when the PR closes before comment creation", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
await publish({
|
||||
github: fakeGithub(calls, {
|
||||
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
|
||||
pullResponses: [
|
||||
currentPullResponse(),
|
||||
currentPullResponse({ state: "closed" }),
|
||||
],
|
||||
}),
|
||||
context: workflowRunContext({ conclusion: "failure" }),
|
||||
core: silentCore(calls),
|
||||
});
|
||||
|
||||
assert.equal(calls.comments.length, 0);
|
||||
assert.match(calls.notices.join("\n"), /PR is no longer open/);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
await publish({
|
||||
@@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) {
|
||||
function currentPullResponse(overrides = {}) {
|
||||
return {
|
||||
data: {
|
||||
state: overrides.state || "open",
|
||||
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
|
||||
base: {
|
||||
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,
|
||||
|
||||
@@ -5,26 +5,42 @@
|
||||
set -euo pipefail
|
||||
|
||||
workflow=".github/workflows/ci.yml"
|
||||
job_section() {
|
||||
local job="$1"
|
||||
awk -v job="$job" '
|
||||
$0 == " " job ":" { in_job = 1; print; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
in_job { print }
|
||||
' "$workflow"
|
||||
}
|
||||
workflow_permissions="$(awk '
|
||||
/^permissions:/ { in_permissions = 1; print; next }
|
||||
in_permissions && /^[^[:space:]]/ { exit }
|
||||
in_permissions { print }
|
||||
' "$workflow")"
|
||||
fast_gate_section="$(job_section fast-gate)"
|
||||
unit_test_section="$(job_section unit-test)"
|
||||
lint_section="$(awk '
|
||||
/^ lint:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ deterministic-gate:/ { exit }
|
||||
/^ script-test:/ { exit }
|
||||
' "$workflow")"
|
||||
script_test_section="$(job_section script-test)"
|
||||
deterministic_section="$(awk '
|
||||
/^ deterministic-gate:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ coverage:/ { exit }
|
||||
' "$workflow")"
|
||||
coverage_job_section="$(job_section coverage)"
|
||||
deadcode_section="$(job_section deadcode)"
|
||||
dry_run_section="$(job_section e2e-dry-run)"
|
||||
section="$(awk '
|
||||
/^ e2e-live:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ security:/ { exit }
|
||||
' "$workflow")"
|
||||
security_section="$(job_section security)"
|
||||
license_header_section="$(job_section license-header)"
|
||||
results_section="$(awk '
|
||||
/^ results:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
@@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should write PR title/body metadata before quality-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
|
||||
echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "script-test:" <<<"$script_test_section"; then
|
||||
echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "make script-test" <<<"$script_test_section"; then
|
||||
echo "script-test job should invoke make script-test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
|
||||
echo "script-test job should install Node for JavaScript workflow tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
|
||||
echo "script-test must not reference secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "metadata-gate:" "$workflow"; then
|
||||
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
|
||||
echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for full_job in \
|
||||
"$unit_test_section" \
|
||||
"$lint_section" \
|
||||
"$script_test_section" \
|
||||
"$deterministic_section" \
|
||||
"$coverage_job_section" \
|
||||
"$dry_run_section" \
|
||||
"$security_section"; do
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
|
||||
echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for pull_request_job in "$deadcode_section" "$license_header_section"; do
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
|
||||
echo "pull_request-only CI jobs must run on edited events"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate must not reference secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should pass public content metadata into make quality-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should pass the pull request branch into public content metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for deterministic-gate"
|
||||
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for script-test and deterministic-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "script-test" <<<"$results_section"; then
|
||||
echo "results job should include script-test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
|
||||
echo "quality-gate check should consume public content metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
|
||||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
|
||||
echo "quality-gate check should consume both exported command snapshots"
|
||||
|
||||
@@ -175,7 +175,7 @@ function inlineCode(value) {
|
||||
}
|
||||
|
||||
function parseEvidenceRef(ref) {
|
||||
const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) {
|
||||
return { kind: parsed.kind, command: item.path, label: item.path };
|
||||
}
|
||||
return null;
|
||||
case "public_content":
|
||||
if (item.file && Number.isInteger(item.line) && item.line > 0) {
|
||||
const label = `${item.file}:${item.line}`;
|
||||
if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
|
||||
return { kind: parsed.kind, label };
|
||||
}
|
||||
return {
|
||||
kind: parsed.kind,
|
||||
path: item.file,
|
||||
line: item.line,
|
||||
label,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
|
||||
repo: context.repo.repo,
|
||||
pull_number: target.pr,
|
||||
});
|
||||
if (pr.state !== "open") {
|
||||
core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.head.sha !== target.headSha) {
|
||||
core.notice(`semantic review skipped: PR head changed before ${phase}`);
|
||||
return false;
|
||||
|
||||
@@ -202,6 +202,100 @@ describe("semantic-review-publish", () => {
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null);
|
||||
});
|
||||
|
||||
it("maps public content evidence to changed files but not virtual metadata", () => {
|
||||
const restrictedScope = "pri" + "vate";
|
||||
const facts = {
|
||||
public_content: [
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "docs/public-roadmap.md",
|
||||
line: 4,
|
||||
source: "file",
|
||||
},
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "pull_request_metadata",
|
||||
line: 1,
|
||||
source: "metadata",
|
||||
},
|
||||
{
|
||||
rule: "public_content_automation_branch",
|
||||
action: "WARNING",
|
||||
file: "branch",
|
||||
line: 1,
|
||||
source: "branch",
|
||||
},
|
||||
{
|
||||
rule: "public_content_change_id_trailer",
|
||||
action: "REJECT",
|
||||
file: "commit:1234abc",
|
||||
line: 3,
|
||||
source: "commit",
|
||||
},
|
||||
],
|
||||
};
|
||||
const changedLineIndex = buildChangedLineIndex([{
|
||||
filename: "docs/public-roadmap.md",
|
||||
patch: [
|
||||
"@@ -3,2 +3,3 @@",
|
||||
" context",
|
||||
"+Specific " + restrictedScope + " roadmap detail",
|
||||
].join("\n"),
|
||||
}]);
|
||||
|
||||
assert.deepEqual(
|
||||
selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex),
|
||||
{ path: "docs/public-roadmap.md", line: 4 },
|
||||
);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null);
|
||||
|
||||
const markdown = buildSummaryMarkdown({
|
||||
block_mode: true,
|
||||
blockers: [{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[1]"],
|
||||
fingerprint: "public-content-metadata",
|
||||
message: "PR metadata contains " + restrictedScope + " rollout detail",
|
||||
suggested_action: "Move " + restrictedScope + " detail to an internal channel.",
|
||||
}],
|
||||
warnings: [],
|
||||
}, facts);
|
||||
assert.match(markdown, /pull_request_metadata:1/);
|
||||
|
||||
const virtualMarkdown = buildSummaryMarkdown({
|
||||
block_mode: true,
|
||||
blockers: [
|
||||
{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[2]"],
|
||||
fingerprint: "public-content-branch",
|
||||
message: "Branch name looks automation-owned.",
|
||||
suggested_action: "Use a maintainer-owned public branch name.",
|
||||
},
|
||||
{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[3]"],
|
||||
fingerprint: "public-content-commit",
|
||||
message: "Commit trailer contains " + restrictedScope + " review metadata.",
|
||||
suggested_action: "Remove " + restrictedScope + " review metadata from commits.",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
}, facts);
|
||||
assert.match(virtualMarkdown, /branch:1/);
|
||||
assert.match(virtualMarkdown, /commit:1234abc:3/);
|
||||
});
|
||||
|
||||
it("builds finding markers from stable fingerprints and evidence identity", () => {
|
||||
const factsA = {
|
||||
skills: [{
|
||||
@@ -615,6 +709,35 @@ describe("semantic-review-publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips publishing when the PR closes after verification", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
fs.writeFileSync("decision.json", JSON.stringify({
|
||||
block_mode: true,
|
||||
blockers: [],
|
||||
warnings: [],
|
||||
}), "utf8");
|
||||
|
||||
await publish({
|
||||
github: fakeGithub(calls, {
|
||||
currentPullRequest: {
|
||||
state: "closed",
|
||||
head: { sha: "0123456789abcdef0123456789abcdef01234567" },
|
||||
base: {
|
||||
sha: "fedcba9876543210fedcba9876543210fedcba98",
|
||||
repo: { id: 123 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
context: workflowRunContext(),
|
||||
core: silentCore(calls),
|
||||
});
|
||||
|
||||
assert.equal(calls.checks.length, 0);
|
||||
assert.equal(calls.comments.length, 0);
|
||||
assert.match(calls.notices[0], /PR is no longer open before publishing/);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects publishing when the PR base repo changed after verification", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
fs.writeFileSync("decision.json", JSON.stringify({
|
||||
@@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) {
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: Array.isArray(options.currentPullRequests)
|
||||
get: async () => {
|
||||
const pull = Array.isArray(options.currentPullRequests)
|
||||
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
|
||||
: options.currentPullRequest || {
|
||||
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
|
||||
@@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) {
|
||||
sha: process.env.SEMANTIC_REVIEW_BASE_SHA,
|
||||
repo: { id: 123 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { data: { state: "open", ...pull } };
|
||||
},
|
||||
listFiles() {},
|
||||
listReviewComments() {},
|
||||
createReviewComment: async (args) => {
|
||||
|
||||
@@ -229,6 +229,36 @@ function requireSafePath(value, path) {
|
||||
return file;
|
||||
}
|
||||
|
||||
function requirePublicContentFile(value, path) {
|
||||
const file = requireString(value, path);
|
||||
if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
|
||||
return file;
|
||||
}
|
||||
if (file.startsWith("commit:")) {
|
||||
throw new Error(`facts JSON ${path} must be a valid public content location`);
|
||||
}
|
||||
requireSafePath(file, path);
|
||||
if (
|
||||
file === "" ||
|
||||
file === "." ||
|
||||
file.startsWith("./") ||
|
||||
file.includes("\\") ||
|
||||
file.includes("\0") ||
|
||||
file.split("/").includes(".git") ||
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
|
||||
) {
|
||||
throw new Error(`facts JSON ${path} must be a repository-relative path`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function requirePositiveLine(value, path) {
|
||||
requireLine(value, path);
|
||||
if (value === 0) {
|
||||
throw new Error(`facts JSON ${path} must be a positive line number`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArray(value, path, { optional = false } = {}) {
|
||||
if (value === undefined || value === null) {
|
||||
if (optional) {
|
||||
@@ -421,6 +451,20 @@ function verifyFactsJSON(data) {
|
||||
for (const [i, value] of requireArray(facts, "examples").entries()) {
|
||||
verifyCommandExample(value, `examples[${i}]`);
|
||||
}
|
||||
for (const [i, value] of requireArray(facts, "public_content").entries()) {
|
||||
const item = requireObject(value, `public_content[${i}]`);
|
||||
requireString(item.rule, `public_content[${i}].rule`);
|
||||
const action = requireString(item.action, `public_content[${i}].action`);
|
||||
if (!VALID_ACTIONS.has(action)) {
|
||||
throw new Error(`facts JSON public_content[${i}].action is invalid`);
|
||||
}
|
||||
requirePublicContentFile(item.file, `public_content[${i}].file`);
|
||||
requirePositiveLine(item.line, `public_content[${i}].line`);
|
||||
requireString(item.source, `public_content[${i}].source`, { optional: true });
|
||||
requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
|
||||
requireString(item.message, `public_content[${i}].message`, { optional: true });
|
||||
requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
|
||||
}
|
||||
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
|
||||
const item = requireObject(value, `diagnostics[${i}]`);
|
||||
requireString(item.rule, `diagnostics[${i}].rule`);
|
||||
|
||||
@@ -67,7 +67,43 @@ describe("verifyZipEntries", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
||||
const zipPath = path.join(dir, "facts.zip");
|
||||
const outPath = path.join(dir, "facts.json");
|
||||
const facts = Buffer.from('{"schema_version":1}\n');
|
||||
const restrictedScope = "pri" + "vate";
|
||||
const facts = Buffer.from(JSON.stringify({
|
||||
schema_version: 1,
|
||||
public_content: [
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "pull_request_metadata",
|
||||
line: 1,
|
||||
source: "metadata",
|
||||
excerpt: "public release notes mention an internal rollout plan",
|
||||
message: "public contribution may contain sensitive implementation detail",
|
||||
suggestion: "move internal detail to " + restrictedScope + " discussion",
|
||||
},
|
||||
{
|
||||
rule: "public_content_change_id_trailer",
|
||||
action: "REJECT",
|
||||
file: "commit:1234abc",
|
||||
line: 3,
|
||||
source: "commit",
|
||||
},
|
||||
{
|
||||
rule: "public_content_automation_branch",
|
||||
action: "WARNING",
|
||||
file: "branch",
|
||||
line: 1,
|
||||
source: "branch",
|
||||
},
|
||||
{
|
||||
rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
action: "WARNING",
|
||||
file: "docs/public-network.md",
|
||||
line: 7,
|
||||
source: "file",
|
||||
},
|
||||
],
|
||||
}) + "\n");
|
||||
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
|
||||
fs.writeFileSync(zipPath, zip);
|
||||
|
||||
@@ -103,6 +139,19 @@ describe("verifyZipEntries", () => {
|
||||
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
|
||||
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
|
||||
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
|
||||
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
|
||||
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
|
||||
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
|
||||
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
|
||||
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
|
||||
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
|
||||
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
|
||||
]) {
|
||||
|
||||
@@ -184,6 +184,10 @@ require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing"
|
||||
require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs"
|
||||
require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure"
|
||||
require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
|
||||
@@ -212,7 +216,12 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl
|
||||
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
|
||||
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
|
||||
require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
|
||||
require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
|
||||
require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
|
||||
require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
@@ -260,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv
|
||||
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
|
||||
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
|
||||
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
|
||||
require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
|
||||
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
|
||||
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"
|
||||
|
||||
|
||||
545
shortcuts/base/base_resolve.go
Normal file
545
shortcuts/base/base_resolve.go
Normal file
@@ -0,0 +1,545 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
|
||||
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepRecordList = "use +record-list to list records in the resolved table"
|
||||
titleResolveQueryMaxLen = 30
|
||||
)
|
||||
|
||||
var BaseURLResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+url-resolve",
|
||||
Description: "Resolve a Base-related URL into Base coordinates",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readURLResolveInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
|
||||
default:
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseURLResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTitleResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+title-resolve",
|
||||
Description: "Resolve a Base title or keyword through Drive search",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:docs:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readTitleResolveQuery(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/search/v2/doc_wiki/search").
|
||||
Body(buildTitleResolveSearchBody(query))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseTitleResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
|
||||
return "", resolveValidationError(
|
||||
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
|
||||
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
}
|
||||
return pickedValue, nil
|
||||
}
|
||||
|
||||
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
out, err := resolveWikiBaseURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
out, err := resolveRecordShareURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "form_share_url":
|
||||
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
|
||||
return nil
|
||||
case "view_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "dashboard_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "workspace_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "add_record_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
default:
|
||||
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func parseResolveURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func classifyBaseURL(u *url.URL) string {
|
||||
path := normalizeResolvePath(u.Path)
|
||||
switch {
|
||||
case pathSegmentExists(path, "/base/workspace/"):
|
||||
return "workspace_url"
|
||||
case pathSegmentExists(path, "/base/add/"):
|
||||
return "add_record_url"
|
||||
case pathSegmentExists(path, "/base/"):
|
||||
return "base_url"
|
||||
case pathSegmentExists(path, "/wiki/"):
|
||||
return "wiki_url"
|
||||
case pathSegmentExists(path, "/record/"):
|
||||
return "record_share_url"
|
||||
case pathSegmentExists(path, "/share/base/form/"):
|
||||
return "form_share_url"
|
||||
case pathSegmentExists(path, "/share/base/view/"):
|
||||
return "view_share_url"
|
||||
case pathSegmentExists(path, "/share/base/dashboard/"):
|
||||
return "dashboard_share_url"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
|
||||
if objType != "bitable" {
|
||||
return nil, resolveValidationError(
|
||||
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
|
||||
"Use the corresponding skill for that resource, or provide a Base URL.",
|
||||
)
|
||||
}
|
||||
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
|
||||
if baseToken == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"input_type": "wiki_url",
|
||||
"resource_type": "bitable",
|
||||
"wiki_node_token": token,
|
||||
"base_token": baseToken,
|
||||
"title": common.GetString(node, "title"),
|
||||
"hint": resolveHint("", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
shareToken := firstPathSegmentAfter(u.Path, "/record/")
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"input_type": "record_share_url",
|
||||
"resource_type": "bitable",
|
||||
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
|
||||
"base_token": common.GetString(data, "base_token"),
|
||||
"table_id": common.GetString(data, "table_id"),
|
||||
"record_id": common.GetString(data, "record_id"),
|
||||
}
|
||||
enrichRecordShareResolveHint(runtime, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveFormShareURL(u *url.URL) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"input_type": "form_share_url",
|
||||
"resource_type": "bitable_form",
|
||||
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return resolveValidationError(
|
||||
"No Base matched this title or keyword.",
|
||||
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
case 1:
|
||||
out := map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"title": candidates[0]["title"],
|
||||
"base_token": candidates[0]["base_token"],
|
||||
"url": candidates[0]["url"],
|
||||
"owner_name": candidates[0]["owner_name"],
|
||||
"update_time": candidates[0]["update_time"],
|
||||
"hint": resolveHint("", nil),
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"candidates": candidates,
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": baseTitleResolveHint,
|
||||
},
|
||||
}, nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
return
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
|
||||
hint := map[string]interface{}{}
|
||||
if baseToken != "" && tableID != "" && recordID != "" {
|
||||
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
|
||||
hint["record_data"] = formatResolvedRecordData(record)
|
||||
}
|
||||
}
|
||||
if baseToken != "" && tableID != "" {
|
||||
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
|
||||
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
|
||||
}
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, hint)
|
||||
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{"record_id_list": []string{recordID}}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
|
||||
return handleBaseAPIResult(result, err, "batch get records")
|
||||
}
|
||||
|
||||
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
|
||||
fieldIDs := common.GetSlice(record, "field_id_list")
|
||||
fieldNames := common.GetSlice(record, "fields")
|
||||
rows := common.GetSlice(record, "data")
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if len(rows) > 0 {
|
||||
if values, ok := rows[0].([]interface{}); ok {
|
||||
for i, value := range values {
|
||||
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
|
||||
if index < len(fieldIDs) {
|
||||
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
|
||||
return fieldID
|
||||
}
|
||||
}
|
||||
if index < len(fieldNames) {
|
||||
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
|
||||
return fieldName
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("field_%d", index+1)
|
||||
}
|
||||
|
||||
func recordShareNextStep(baseToken, tableID, recordID string) string {
|
||||
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
|
||||
hint := map[string]interface{}{}
|
||||
for key, value := range extra {
|
||||
hint[key] = value
|
||||
}
|
||||
if strings.TrimSpace(tableID) != "" {
|
||||
hint["next_step"] = nextStepRecordList
|
||||
} else {
|
||||
hint["next_step"] = nextStepBaseBlockList
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
func buildTitleResolveSearchBody(query string) map[string]interface{} {
|
||||
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
|
||||
return map[string]interface{}{
|
||||
"query": query,
|
||||
"page_size": 5,
|
||||
"doc_filter": filter,
|
||||
"wiki_filter": filter,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
|
||||
candidates := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
meta, _ := row["result_meta"].(map[string]interface{})
|
||||
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(common.GetString(meta, "token"))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(common.GetString(row, "title"))
|
||||
}
|
||||
candidates = append(candidates, map[string]interface{}{
|
||||
"title": title,
|
||||
"base_token": token,
|
||||
"url": common.GetString(meta, "url"),
|
||||
"owner_name": common.GetString(meta, "owner_name"),
|
||||
"update_time": common.GetString(meta, "update_time_iso"),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
|
||||
|
||||
func stripSearchHighlight(s string) string {
|
||||
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
func resolveValidationError(message, hint string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func normalizeResolvePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathSegmentExists(path, prefix string) bool {
|
||||
return firstPathSegmentAfter(path, prefix) != ""
|
||||
}
|
||||
|
||||
func firstPathSegmentAfter(path, prefix string) string {
|
||||
path = normalizeResolvePath(path)
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
func valueOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "an unknown resource type"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
454
shortcuts/base/base_resolve_test.go
Normal file
454
shortcuts/base/base_resolve_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base only", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepBaseBlockList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not Base") {
|
||||
t.Fatalf("err=%v, want non-Base validation error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
recordData, _ := hint["record_data"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enrichment failure still returns meta", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["record_data"]; ok {
|
||||
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_share_token": shareToken,
|
||||
"base_token": baseToken,
|
||||
"table_id": tableID,
|
||||
"record_id": recordID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveFormShareURL(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantText string
|
||||
wantHint string
|
||||
}{
|
||||
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
|
||||
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
|
||||
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
|
||||
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
|
||||
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
|
||||
{"not url", "bas123", "only accepts full URLs", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", tc.rawURL, "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
|
||||
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("err=%v, want typed error with hint", err)
|
||||
}
|
||||
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
|
||||
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "original /base/{base_token}") {
|
||||
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
shortcut string
|
||||
definition common.Shortcut
|
||||
primaryFlag string
|
||||
primaryDesc string
|
||||
aliasFlags []string
|
||||
}{
|
||||
{
|
||||
shortcut: "+url-resolve",
|
||||
definition: BaseURLResolve,
|
||||
primaryFlag: "url",
|
||||
primaryDesc: "Base/Wiki/record-share URL to resolve",
|
||||
aliasFlags: []string{"query"},
|
||||
},
|
||||
{
|
||||
shortcut: "+title-resolve",
|
||||
definition: BaseTitleResolve,
|
||||
primaryFlag: "title",
|
||||
primaryDesc: "Base title keyword",
|
||||
aliasFlags: []string{"query", "url"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tc.definition.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
primary := cmd.Flags().Lookup(tc.primaryFlag)
|
||||
primaryUsage := ""
|
||||
if primary != nil {
|
||||
primaryUsage = primary.Usage
|
||||
}
|
||||
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
|
||||
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTitleResolve(t *testing.T) {
|
||||
t.Run("single result", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Sales <h>Pipeline</h>",
|
||||
"result_meta": map[string]interface{}{
|
||||
"doc_types": "BITABLE",
|
||||
"token": "bas123",
|
||||
"url": "https://example.larkoffice.com/base/bas123",
|
||||
"owner_name": "Alice",
|
||||
"update_time_iso": "2026-06-09T10:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple results and filter non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Doc hit",
|
||||
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>One</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>Two</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--url", "Base", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates=%#v, want 2", data["candidates"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub(nil))
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "missing", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
|
||||
t.Fatalf("err=%v, want no result validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query too long", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
|
||||
t.Fatalf("err=%v, want query length validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"res_units": items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"total": 2,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
|
||||
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{recordID},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_status"},
|
||||
"fields": []interface{}{"Name", "Status"},
|
||||
"data": []interface{}{[]interface{}{"Alice", "Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
|
||||
@@ -8,6 +8,8 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseURLResolve,
|
||||
BaseTitleResolve,
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
|
||||
@@ -199,16 +199,7 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +142,7 @@ func TestNormalizeMCPToolResult(t *testing.T) {
|
||||
|
||||
got, err := normalizeMCPToolResult(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,7 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -1029,7 +1030,6 @@ func stripUTF8BOM(s string) string {
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
@@ -1049,11 +1049,14 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
// A process has a single stdin, so we reject a second Input flag
|
||||
// trying to use `-` after the first one has already consumed it.
|
||||
if rctx.stdinConsumed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
rctx.stdinConsumed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
@@ -1166,7 +1169,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
hints = append(hints, "@file")
|
||||
}
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
hints = append(hints, "- for stdin")
|
||||
// "- reads stdin" intentionally avoids implying each flag has
|
||||
// its own stdin: a process has a single stdin, so at most one
|
||||
// flag per call may use "-" (the rest must use @file). The old
|
||||
// per-flag "- for stdin" wording led AI agents to write
|
||||
// `--a - <x --b - <y`, where the second `<` silently clobbers
|
||||
// the first and `--a` reads the wrong payload.
|
||||
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
|
||||
}
|
||||
desc += " (supports " + strings.Join(hints, ", ") + ")"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for positional arg, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("expected positional args rejection message, got: %v", err)
|
||||
}
|
||||
@@ -39,6 +40,7 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple positional args, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
@@ -197,6 +198,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
@@ -218,6 +220,7 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
@@ -238,7 +241,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
@@ -279,6 +282,7 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
@@ -291,6 +295,7 @@ func TestBotInfo_NilFunc(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support stdin") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support file input") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "cannot read file") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,9 +216,14 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
// The hint must steer an AI agent to the fix (@file for the extra flags),
|
||||
// since `--a - <x --b - <y` is the exact misuse this guards against.
|
||||
if !strings.Contains(vErr.Hint, "@file") {
|
||||
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,9 +186,7 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
@@ -208,9 +206,7 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
requireValidation(t, err, "invalid jq expression")
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
|
||||
50
shortcuts/common/typed_error_assertions_test.go
Normal file
50
shortcuts/common/typed_error_assertions_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
|
||||
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -203,6 +203,13 @@ func TestValidateCreateV2Contract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) {
|
||||
rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil)
|
||||
if err := validateCreateV2(context.Background(), rt); err != nil {
|
||||
t.Fatalf("validateCreateV2() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2Contract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -55,7 +55,7 @@ var docCoverAllowedContentTypes = map[string]string{
|
||||
|
||||
var DocResourceDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-download",
|
||||
Command: "+resource-download",
|
||||
Description: "Download a document resource (type=cover downloads the cover image content)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
||||
@@ -154,7 +154,7 @@ var DocResourceDownload = common.Shortcut{
|
||||
|
||||
var DocResourceUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-update",
|
||||
Command: "+resource-update",
|
||||
Description: "Upload and update a document resource (type=cover)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
||||
@@ -256,7 +256,7 @@ var DocResourceUpdate = common.Shortcut{
|
||||
|
||||
var DocResourceDelete = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-delete",
|
||||
Command: "+resource-delete",
|
||||
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"+resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover",
|
||||
@@ -95,7 +95,7 @@ func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T)
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"+resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover.png",
|
||||
@@ -116,7 +116,7 @@ func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"+resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -146,7 +146,7 @@ func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"+resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -195,7 +195,7 @@ func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *tes
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -241,7 +241,7 @@ func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", "doxcnCoverValidate1",
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -258,7 +258,7 @@ func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", "doxcnCoverValidateRequired1",
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -273,7 +273,7 @@ func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", "doxcnCoverURLValidate1",
|
||||
"--type", "cover",
|
||||
"--url", "https://127.0.0.1/cover.png",
|
||||
@@ -617,7 +617,7 @@ func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Command] = true
|
||||
}
|
||||
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
|
||||
for _, want := range []string{"+resource-download", "+resource-update", "+resource-delete"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("Shortcuts() missing %s", want)
|
||||
}
|
||||
|
||||
@@ -282,7 +282,6 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "user",
|
||||
})
|
||||
@@ -292,8 +291,7 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"docs +create is v2-only",
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --title, --markdown are no longer supported",
|
||||
"--title -> put the title in --content",
|
||||
"legacy v1 flag(s) --markdown are no longer supported",
|
||||
"--markdown -> use --content with --doc-format markdown",
|
||||
"lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
@@ -25,8 +28,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("content") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -66,7 +73,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"content": runtime.Str("content"),
|
||||
"content": buildCreateContent(runtime),
|
||||
}
|
||||
if v := runtime.Str("parent-token"); v != "" {
|
||||
body["parent_token"] = v
|
||||
@@ -78,6 +85,26 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
content := runtime.Str("content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
return titleTag
|
||||
}
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// augmentDocsCreatePermission grants full_access to the current CLI user when
|
||||
// the document was created with bot identity.
|
||||
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
|
||||
|
||||
861
shortcuts/doc/docs_fetch_im_markdown.go
Normal file
861
shortcuts/doc/docs_fetch_im_markdown.go
Normal file
@@ -0,0 +1,861 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type imMarkdownContext struct {
|
||||
baseURL string
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
|
||||
|
||||
type imMarkdownTagHandler struct {
|
||||
closeRE *regexp.Regexp
|
||||
handle imMarkdownHandleFunc
|
||||
}
|
||||
|
||||
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
|
||||
imMarkdownHandlers[tag] = imMarkdownTagHandler{
|
||||
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
|
||||
handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
|
||||
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
|
||||
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
|
||||
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
|
||||
)
|
||||
|
||||
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
|
||||
|
||||
func init() {
|
||||
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
|
||||
for level := 1; level <= 9; level++ {
|
||||
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
|
||||
}
|
||||
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
|
||||
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
|
||||
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
|
||||
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
|
||||
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
|
||||
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
|
||||
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
|
||||
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
|
||||
registerIMMarkdownHandler("table", handleIMMarkdownTable)
|
||||
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
|
||||
registerIMMarkdownHandler("code", handleIMMarkdownCode)
|
||||
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
|
||||
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
|
||||
registerIMMarkdownHandler("img", handleIMMarkdownImage)
|
||||
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("source", handleIMMarkdownSource)
|
||||
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
|
||||
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
|
||||
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
|
||||
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
|
||||
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
|
||||
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
|
||||
}
|
||||
|
||||
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
|
||||
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
|
||||
}
|
||||
|
||||
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
|
||||
}
|
||||
|
||||
func newIMMarkdownContext(docInput string) imMarkdownContext {
|
||||
base := "https://larkoffice.com"
|
||||
raw := strings.TrimSpace(docInput)
|
||||
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
|
||||
base = extracted
|
||||
}
|
||||
return imMarkdownContext{baseURL: base}
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
|
||||
c.blockquoteDepth++
|
||||
return c
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) inBlockquote() bool {
|
||||
return c.blockquoteDepth > 0
|
||||
}
|
||||
|
||||
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
|
||||
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
|
||||
// intentionally strips host information, so it cannot serve this formatting path.
|
||||
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate := strings.Trim(raw[:idx], "/")
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
|
||||
return "https://" + u.Host, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset := 0; offset < len(content); {
|
||||
// Scan only to the next XML-like opening tag. Plain Markdown text between
|
||||
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
|
||||
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
start := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
|
||||
handler, ok := imMarkdownHandlers[tag]
|
||||
if !ok {
|
||||
// Unknown tags are left intact. im-markdown only downgrades tags with
|
||||
// explicit handlers so future server output does not get guessed at.
|
||||
out.WriteString(content[offset:openEnd])
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(content[offset:start])
|
||||
opening := content[start:openEnd]
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
out.WriteString(handler.handle(opening, "", attrs, imCtx))
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the handler's precompiled close regexp to find the matching end tag.
|
||||
// Depth tracking keeps nested same-name containers paired correctly.
|
||||
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
|
||||
if !found {
|
||||
// Malformed or truncated fragments are preserved as-is from the opening
|
||||
// tag onward; do not drop content when the XML-ish structure is incomplete.
|
||||
out.WriteString(content[start:])
|
||||
break
|
||||
}
|
||||
segment := content[start:closeEnd]
|
||||
inner := content[openEnd:closeStart]
|
||||
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
|
||||
offset = closeEnd
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseIMMarkdownAttrs(opening string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
|
||||
value := match[2]
|
||||
if value == "" {
|
||||
value = match[3]
|
||||
}
|
||||
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func isSelfClosingIMMarkdownTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
|
||||
}
|
||||
|
||||
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "# " + text
|
||||
}
|
||||
|
||||
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
|
||||
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
markdownLevel := level
|
||||
if markdownLevel > 6 {
|
||||
markdownLevel = 6
|
||||
}
|
||||
return strings.Repeat("#", markdownLevel) + " " + text
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if imCtx.inBlockquote() {
|
||||
return body + "\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, false, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, true, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
prefix := "-"
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
emoji := strings.TrimSpace(attrs["emoji"])
|
||||
if emoji != "" {
|
||||
if body == "" {
|
||||
body = emoji
|
||||
} else {
|
||||
body = emoji + " " + body
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return "---\n---"
|
||||
}
|
||||
return fmt.Sprintf("---\n%s\n---", body)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
lines[i] = ">"
|
||||
continue
|
||||
}
|
||||
lines[i] = "> " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return body + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
lang := strings.TrimSpace(attrs["lang"])
|
||||
code := strings.TrimSpace(inner)
|
||||
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
|
||||
code = match[1]
|
||||
}
|
||||
return imMarkdownFencedCode(html.UnescapeString(code), lang)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(markdownPlainText(inner))
|
||||
}
|
||||
|
||||
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
expr := strings.TrimSpace(markdownPlainText(inner))
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
|
||||
}
|
||||
|
||||
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return "---"
|
||||
}
|
||||
|
||||
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
|
||||
return fmt.Sprintf("", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
|
||||
}
|
||||
|
||||
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
name := strings.TrimSpace(attrs["name"])
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return imMarkdownInlineCode(name)
|
||||
}
|
||||
|
||||
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
for _, attrName := range attrNames {
|
||||
if strings.TrimSpace(attrs[attrName]) != "" {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
token := strings.TrimSpace(attrs["token"])
|
||||
if token == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
label := "sheet"
|
||||
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
|
||||
label = "sheet " + sheetID
|
||||
}
|
||||
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
|
||||
if href == "" {
|
||||
return name
|
||||
}
|
||||
return markdownLink(name, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "**" + body + "**"
|
||||
}
|
||||
|
||||
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "*" + body + "*"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "~~" + body + "~~"
|
||||
}
|
||||
|
||||
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
|
||||
if href == "" {
|
||||
return text
|
||||
}
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
|
||||
case "user":
|
||||
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
|
||||
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
|
||||
if userID == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
|
||||
case "doc":
|
||||
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(title, href)
|
||||
}
|
||||
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
|
||||
if docID == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
|
||||
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
|
||||
case "citation":
|
||||
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
|
||||
}
|
||||
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
|
||||
default:
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
// Rows and cells are matched with tag-depth tracking instead of non-greedy
|
||||
// regex captures. A table nested inside a cell can contain its own </tr> and
|
||||
// </td>; treating those as the outer row/cell boundary corrupts the table.
|
||||
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
|
||||
if len(rowBodies) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(rowBodies))
|
||||
for _, rowBody := range rowBodies {
|
||||
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
|
||||
if len(cellBodies) == 0 {
|
||||
continue
|
||||
}
|
||||
row := make([]string, 0, len(cellBodies))
|
||||
for _, cellBody := range cellBodies {
|
||||
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
cols := 0
|
||||
for _, row := range rows {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
|
||||
separator := make([]string, cols)
|
||||
for i := range separator {
|
||||
separator[i] = "-"
|
||||
}
|
||||
writeIMMarkdownTableRow(&out, separator)
|
||||
for _, row := range rows[1:] {
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// extractIMMarkdownElementBodies returns the inner content of each top-level
|
||||
// element matched by tagRE. tagRE must expose the optional closing slash as its
|
||||
// first capture group, matching the row/cell regexes above.
|
||||
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
|
||||
var bodies []string
|
||||
for offset := 0; offset < len(content); {
|
||||
loc := tagRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := content[openStart:openEnd]
|
||||
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
bodies = append(bodies, "")
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
bodies = append(bodies, content[openEnd:closeStart])
|
||||
offset = closeEnd
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func normalizeIMMarkdownTableCell(cell string) string {
|
||||
const brPlaceholder = "\x00BR\x00"
|
||||
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
|
||||
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
|
||||
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
|
||||
if name == "at" {
|
||||
return tag
|
||||
}
|
||||
return ""
|
||||
})
|
||||
cell = html.UnescapeString(cell)
|
||||
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
|
||||
cell = strings.ReplaceAll(cell, " \n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "\n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "|", `\|`)
|
||||
lines := strings.Fields(cell)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
|
||||
out.WriteString("| ")
|
||||
out.WriteString(strings.Join(row, " | "))
|
||||
out.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func padIMMarkdownTableRow(row []string, cols int) []string {
|
||||
if len(row) >= cols {
|
||||
return row
|
||||
}
|
||||
padded := make([]string, cols)
|
||||
copy(padded, row)
|
||||
return padded
|
||||
}
|
||||
|
||||
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset, index := 0, 1; offset < len(inner); {
|
||||
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := inner[openStart:openEnd]
|
||||
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
|
||||
if body != "" {
|
||||
prefix := "-"
|
||||
if ordered {
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
} else {
|
||||
prefix = fmt.Sprintf("%d.", index)
|
||||
}
|
||||
index++
|
||||
}
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(" ")
|
||||
out.WriteString(indentIMMarkdownListContinuation(body))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
offset = closeEnd
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func indentIMMarkdownListContinuation(body string) string {
|
||||
return strings.ReplaceAll(body, "\n", "\n ")
|
||||
}
|
||||
|
||||
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
|
||||
match := imMarkdownLinkRE.FindStringSubmatch(inner)
|
||||
if match == nil {
|
||||
return "", "", false
|
||||
}
|
||||
href := match[1]
|
||||
if href == "" {
|
||||
href = match[2]
|
||||
}
|
||||
text := strings.TrimSpace(markdownPlainText(match[3]))
|
||||
if text == "" {
|
||||
text = href
|
||||
}
|
||||
return text, html.UnescapeString(href), true
|
||||
}
|
||||
|
||||
func markdownPlainText(s string) string {
|
||||
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
|
||||
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func markdownLinkLabelText(s string) string {
|
||||
text := markdownPlainText(s)
|
||||
if !strings.Contains(text, "---") {
|
||||
return text
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
kept := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
func markdownLink(text, href string) string {
|
||||
cleanHref := strings.TrimSpace(href)
|
||||
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkText(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, `[`, `\[`)
|
||||
text = strings.ReplaceAll(text, `]`, `\]`)
|
||||
return text
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkDestination(href string) string {
|
||||
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
|
||||
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
|
||||
// characters that can terminate or split the Markdown link destination.
|
||||
var out strings.Builder
|
||||
out.Grow(len(href))
|
||||
for i := 0; i < len(href); {
|
||||
if href[i] == '%' {
|
||||
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
|
||||
out.WriteString(href[i : i+3])
|
||||
i += 3
|
||||
} else {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if href[i] < utf8.RuneSelf {
|
||||
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
} else {
|
||||
out.WriteByte(href[i])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(href[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
for _, b := range []byte(href[i : i+size]) {
|
||||
writePercentEncodedByte(&out, b)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
|
||||
if b <= ' ' || b >= 0x7f {
|
||||
return true
|
||||
}
|
||||
switch b {
|
||||
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writePercentEncodedByte(out *strings.Builder, b byte) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hex[b>>4])
|
||||
out.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
|
||||
func isHexDigit(b byte) bool {
|
||||
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
|
||||
}
|
||||
|
||||
func imMarkdownInlineCode(s string) string {
|
||||
maxRun := 0
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r == '`' {
|
||||
run++
|
||||
if run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
fence := strings.Repeat("`", maxRun+1)
|
||||
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
|
||||
return fence + " " + s + " " + fence
|
||||
}
|
||||
return fence + s + fence
|
||||
}
|
||||
|
||||
func imMarkdownFencedCode(code, lang string) string {
|
||||
maxRun := 0
|
||||
for _, line := range strings.Split(code, "\n") {
|
||||
if run := leadingBacktickRun(line); run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
}
|
||||
fenceLen := maxRun + 1
|
||||
if fenceLen < 3 {
|
||||
fenceLen = 3
|
||||
}
|
||||
fence := strings.Repeat("`", fenceLen)
|
||||
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
|
||||
}
|
||||
|
||||
func leadingBacktickRun(s string) int {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r != '`' {
|
||||
break
|
||||
}
|
||||
run++
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
1305
shortcuts/doc/docs_fetch_im_markdown_test.go
Normal file
1305
shortcuts/doc/docs_fetch_im_markdown_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ import (
|
||||
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
|
||||
func v2FetchFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
|
||||
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
|
||||
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
|
||||
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
@@ -72,6 +72,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
|
||||
}
|
||||
if isIMMarkdownFetch(runtime) {
|
||||
applyFetchIMMarkdown(data, runtime.Str("doc"))
|
||||
}
|
||||
|
||||
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
|
||||
if doc, ok := data["document"].(map[string]interface{}); ok {
|
||||
@@ -85,7 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"format": effectiveFetchFormat(runtime),
|
||||
}
|
||||
if v := runtime.Int("revision-id"); v > 0 {
|
||||
body["revision_id"] = v
|
||||
@@ -122,6 +125,14 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func effectiveFetchFormat(runtime *common.RuntimeContext) string {
|
||||
format := strings.TrimSpace(runtime.Str("doc-format"))
|
||||
if format == "im-markdown" {
|
||||
return "markdown"
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func resolveFetchLang(runtime *common.RuntimeContext) string {
|
||||
if runtime.Changed("lang") {
|
||||
return strings.TrimSpace(runtime.Str("lang"))
|
||||
|
||||
@@ -6,9 +6,12 @@ package doc
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -40,6 +43,23 @@ func TestBuildCreateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreateBodyPrependsTitleToContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newCreateBodyTestRuntime(context.Background())
|
||||
if err := runtime.Cmd.Flags().Set("title", "A & B <C>"); err != nil {
|
||||
t.Fatalf("set title: %v", err)
|
||||
}
|
||||
if err := runtime.Cmd.Flags().Set("content", "## Body"); err != nil {
|
||||
t.Fatalf("set content: %v", err)
|
||||
}
|
||||
|
||||
body := buildCreateBody(runtime)
|
||||
if got, want := body["content"], "<title>A & B <C></title>\n## Body"; got != want {
|
||||
t.Fatalf("content = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUpdateBodyIncludesSceneFromContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -104,6 +124,369 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "revision-id", "42")
|
||||
mustSetFetchFlag(t, runtime, "detail", "full")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
if got := body["revision_id"]; got != 42 {
|
||||
t.Fatalf("revision_id = %#v, want 42", got)
|
||||
}
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
"export_style_attrs": true,
|
||||
"export_cite_extra_data": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "detail", "with-ids")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
exportOption, _ := body["export_option"].(map[string]interface{})
|
||||
want := map[string]interface{}{
|
||||
"export_block_id": true,
|
||||
}
|
||||
if !reflect.DeepEqual(exportOption, want) {
|
||||
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
mustSetFetchFlag(t, runtime, "scope", "section")
|
||||
mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
|
||||
|
||||
body := buildFetchBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
}
|
||||
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("read_option = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReadOptionModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full omits read option",
|
||||
setFlags: map[string]string{
|
||||
"scope": "full",
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "outline with max depth",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
"max-depth": "3",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "outline",
|
||||
"max_depth": "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with block ids and context",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"end-block-id": "blk_end",
|
||||
"context-before": "2",
|
||||
"context-after": "1",
|
||||
"max-depth": "0",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "range",
|
||||
"start_block_id": "blk_start",
|
||||
"end_block_id": "blk_end",
|
||||
"context_before": "2",
|
||||
"context_after": "1",
|
||||
"max_depth": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with query",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context-before": "1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "keyword",
|
||||
"keyword": "foo|bar",
|
||||
"context_before": "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section keeps unlimited depth omitted",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
"max-depth": "-1",
|
||||
},
|
||||
want: map[string]interface{}{
|
||||
"read_mode": "section",
|
||||
"start_block_id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{
|
||||
name: "negative context before",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-before": "-1",
|
||||
},
|
||||
wantParam: "--context-before",
|
||||
},
|
||||
{
|
||||
name: "negative context after",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"context-after": "-1",
|
||||
},
|
||||
wantParam: "--context-after",
|
||||
},
|
||||
{
|
||||
name: "max depth below unlimited sentinel",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"start-block-id": "blk_start",
|
||||
"max-depth": "-2",
|
||||
},
|
||||
wantParam: "--max-depth",
|
||||
},
|
||||
{
|
||||
name: "range needs boundary",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
},
|
||||
wantParams: []string{
|
||||
"--start-block-id",
|
||||
"--end-block-id",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword needs keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
},
|
||||
wantParam: "--keyword",
|
||||
},
|
||||
{
|
||||
name: "section needs start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
},
|
||||
wantParam: "--start-block-id",
|
||||
},
|
||||
{
|
||||
name: "unknown scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
err := validateReadModeFlags(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateReadModeFlags() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "outline",
|
||||
setFlags: map[string]string{
|
||||
"scope": "outline",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "range with end block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "range",
|
||||
"end-block-id": "blk_end",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keyword with keyword",
|
||||
setFlags: map[string]string{
|
||||
"scope": "keyword",
|
||||
"keyword": "bug|缺陷",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "section with start block",
|
||||
setFlags: map[string]string{
|
||||
"scope": "section",
|
||||
"start-block-id": "blk_heading",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
if err := validateReadModeFlags(runtime); err != nil {
|
||||
t.Fatalf("validateReadModeFlags() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "invalid doc",
|
||||
setFlags: map[string]string{
|
||||
"doc": "https://example.com/sheets/sht_token",
|
||||
},
|
||||
wantParam: "--doc",
|
||||
},
|
||||
{
|
||||
name: "invalid scope",
|
||||
setFlags: map[string]string{
|
||||
"scope": "bad",
|
||||
},
|
||||
wantParam: "--scope",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
|
||||
err := validateFetchV2(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("validateFetchV2() succeeded, want error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setFlags map[string]string
|
||||
}{
|
||||
{
|
||||
name: "xml format",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "xml",
|
||||
"detail": "full",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "markdown simple detail",
|
||||
setFlags: map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": "simple",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchBodyTestRuntime(context.Background())
|
||||
for name, value := range tt.setFlags {
|
||||
mustSetFetchFlag(t, runtime, name, value)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
|
||||
t.Fatalf("warning = %q, want empty", got)
|
||||
}
|
||||
if _, ok := data["warnings"]; ok {
|
||||
t.Fatalf("unexpected warnings: %#v", data["warnings"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -141,36 +524,54 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "im-markdown",
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
if got, want := dry.API[0].Body["format"], "markdown"; got != want {
|
||||
t.Fatalf("dry-run format = %#v, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, format := range []string{"markdown", "im-markdown"} {
|
||||
for _, detail := range []string{"with-ids", "full"} {
|
||||
t.Run(format+"/"+detail, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": "markdown",
|
||||
"detail": detail,
|
||||
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
|
||||
"doc-format": format,
|
||||
"detail": detail,
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
if err := validateFetchV2(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("validateFetchV2() error = %v", err)
|
||||
}
|
||||
|
||||
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
|
||||
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
|
||||
if exportOption == nil {
|
||||
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
|
||||
}
|
||||
if got := exportOption["export_block_id"]; got != false {
|
||||
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_style_attrs"]; got != false {
|
||||
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
if got := exportOption["export_cite_extra_data"]; got != false {
|
||||
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +662,107 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 999999,
|
||||
"msg": "fetch failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchAPIError",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mountAndRunDocs() succeeded, want API error")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if p.Code != 999999 {
|
||||
t.Errorf("code = %d, want 999999", p.Code)
|
||||
}
|
||||
if p.Message != "fetch failed" {
|
||||
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
|
||||
}
|
||||
if cause := errors.Unwrap(err); cause != nil {
|
||||
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcnFetchIMMarkdown",
|
||||
"revision_id": float64(1),
|
||||
"content": strings.Join([]string{
|
||||
`<title>Doc Title</title>`,
|
||||
`<callout emoji="💡">Read **this**.</callout>`,
|
||||
`<bookmark name="Example" href="https://example.com"></bookmark>`,
|
||||
}, "\n\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsFetch, []string{
|
||||
"+fetch",
|
||||
"--doc", "doxcnFetchIMMarkdown",
|
||||
"--doc-format", "im-markdown",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
content, _ := doc["content"].(string)
|
||||
for _, want := range []string{
|
||||
"# Doc Title",
|
||||
"---\n💡 Read **this**.\n---",
|
||||
"[Example](https://example.com)",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("converted content missing %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "<title>") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") {
|
||||
t.Fatalf("converted content still contains downgraded XML tags:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -291,6 +793,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected v2-only validation error")
|
||||
}
|
||||
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
|
||||
for _, want := range tt.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error missing %q: %v", want, err)
|
||||
@@ -316,6 +819,14 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
|
||||
}
|
||||
|
||||
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
|
||||
t.Helper()
|
||||
|
||||
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -351,6 +862,7 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
|
||||
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "+create"}
|
||||
cmd.Flags().String("doc-format", "xml", "")
|
||||
cmd.Flags().String("title", "", "")
|
||||
cmd.Flags().String("content", "<title>hello</title>", "")
|
||||
cmd.Flags().String("parent-token", "", "")
|
||||
cmd.Flags().String("parent-position", "", "")
|
||||
|
||||
@@ -25,7 +25,6 @@ func docsAPIVersionCompatFlag() common.Flag {
|
||||
|
||||
func docsCreateLegacyFlags() []docsLegacyFlag {
|
||||
return []docsLegacyFlag{
|
||||
{Name: "title", Replacement: "put the title in --content, for example <title>Title</title>"},
|
||||
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
|
||||
{Name: "folder-token", Replacement: "use --parent-token"},
|
||||
{Name: "wiki-node", Replacement: "use --parent-token"},
|
||||
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,6 +16,24 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// wrapExportContextErr converts a context cancellation / deadline error into a
|
||||
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
|
||||
// preserved for errors.Is) instead of an untyped context.Canceled /
|
||||
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
|
||||
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
|
||||
func wrapExportContextErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
msg := "drive +export polling cancelled: %s"
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
subtype = errs.SubtypeNetworkTimeout
|
||||
msg = "drive +export polling deadline exceeded: %s"
|
||||
}
|
||||
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
|
||||
}
|
||||
|
||||
// DriveExport exports Drive-native documents to local files and falls back to
|
||||
// a follow-up command when the async export task does not finish in time.
|
||||
var DriveExport = common.Shortcut{
|
||||
@@ -40,236 +59,305 @@ var DriveExport = common.Shortcut{
|
||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveExportSpec(driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
})
|
||||
return validateExport(exportParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
// ExportParams holds the user-facing inputs for an export flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
|
||||
// the drive export implementation. An empty OutputDir means "create the export
|
||||
// task and poll, but do not download" — callers that only need the ready file
|
||||
// token / status get it back without writing a local file.
|
||||
type ExportParams struct {
|
||||
Token string
|
||||
DocType string
|
||||
FileExtension string
|
||||
SubID string
|
||||
OnlySchema bool
|
||||
OutputDir string
|
||||
FileName string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
func (p ExportParams) spec() driveExportSpec {
|
||||
return driveExportSpec{
|
||||
Token: p.Token,
|
||||
DocType: p.DocType,
|
||||
FileExtension: p.FileExtension,
|
||||
SubID: p.SubID,
|
||||
OnlySchema: p.OnlySchema,
|
||||
}
|
||||
}
|
||||
|
||||
// exportParamsFromFlags reads the standard drive +export flag set.
|
||||
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
|
||||
// drive +export always downloads; an empty --output-dir historically means
|
||||
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
|
||||
// it here to keep behavior identical and stay off the export-only ("" => skip
|
||||
// download) path that only sheets +workbook-export uses.
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir == "" {
|
||||
outputDir = "."
|
||||
}
|
||||
return ExportParams{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
OutputDir: outputDir,
|
||||
FileName: strings.TrimSpace(runtime.Str("file-name")),
|
||||
Overwrite: runtime.Bool("overwrite"),
|
||||
}
|
||||
}
|
||||
|
||||
// validateExport runs the CLI-level export constraint checks. Unexported because
|
||||
// only drive +export's Validate consumes it directly; sheets +workbook-export
|
||||
// reuses RunExport / PlanExportDryRun but inlines its own (sheet-specific)
|
||||
// validation, so there is no cross-package call site to keep exported.
|
||||
func validateExport(p ExportParams) error {
|
||||
return validateDriveExportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
|
||||
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
// Markdown export is a special case: docx markdown comes from the V2
|
||||
// docs_ai fetch API directly instead of the Drive export task API.
|
||||
if spec.FileExtension == "markdown" {
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", runtime.Str("output-dir"))
|
||||
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
|
||||
Desc("2-step orchestration: fetch docx markdown -> write local file").
|
||||
POST(apiPath).
|
||||
Body(map[string]interface{}{
|
||||
"format": "markdown",
|
||||
}).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveExportSpec{
|
||||
Token: runtime.Str("token"),
|
||||
DocType: runtime.Str("doc-type"),
|
||||
FileExtension: runtime.Str("file-extension"),
|
||||
SubID: runtime.Str("sub-id"),
|
||||
OnlySchema: runtime.Bool("only-schema"),
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
}
|
||||
if strings.TrimSpace(spec.SubID) != "" {
|
||||
body["sub_id"] = spec.SubID
|
||||
}
|
||||
if spec.OnlySchema {
|
||||
body["only_schema"] = true
|
||||
}
|
||||
|
||||
dr := common.NewDryRunAPI().
|
||||
Desc("3-step orchestration: create export task -> limited polling -> download file").
|
||||
POST("/open-apis/drive/v1/export_tasks").
|
||||
Body(body).
|
||||
Set("output_dir", p.OutputDir)
|
||||
if name := strings.TrimSpace(p.FileName); name != "" {
|
||||
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
|
||||
}
|
||||
return dr
|
||||
}
|
||||
|
||||
// RunExport drives create export task -> bounded poll -> optional download. It
|
||||
// is the shared core behind both drive +export and sheets +workbook-export. An
|
||||
// empty p.OutputDir skips the download step and returns the ready file token.
|
||||
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
|
||||
spec := p.spec()
|
||||
outputDir := p.OutputDir
|
||||
preferredFileName := strings.TrimSpace(p.FileName)
|
||||
overwrite := p.Overwrite
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
|
||||
// Markdown export bypasses the async export task and writes the fetched
|
||||
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
|
||||
// higher-quality Lark-flavored Markdown output.
|
||||
if spec.FileExtension == "markdown" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
|
||||
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
apiPath,
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"format": "markdown",
|
||||
},
|
||||
)
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
return err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract content from the V2 response: data.document.content
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return wrapExportContextErr(ctx.Err())
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return wrapExportContextErr(err)
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
|
||||
// Export-only mode: caller wants the ready file token / metadata but
|
||||
// no local download (e.g. sheets +workbook-export without an output
|
||||
// path). Skip the download and return the status envelope.
|
||||
if strings.TrimSpace(outputDir) == "" {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_token": status.FileToken,
|
||||
"file_name": status.FileName,
|
||||
"file_size": status.FileSize,
|
||||
"ready": true,
|
||||
"downloaded": false,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
// Prefer the remote title for the exported file name, but still fall
|
||||
// back to the token if metadata is empty.
|
||||
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
|
||||
title = spec.Token
|
||||
}
|
||||
fileName = title
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"file_name": filepath.Base(savedPath),
|
||||
"saved_path": savedPath,
|
||||
"size_bytes": len(content),
|
||||
}, nil)
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
ticket, err := createDriveExportTask(runtime, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
|
||||
|
||||
var lastStatus driveExportStatus
|
||||
var lastPollErr error
|
||||
hasObservedStatus := false
|
||||
// Keep the command responsive by polling for a bounded window. If the task
|
||||
// is still running after that, return a resume command instead of blocking.
|
||||
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(driveExportPollInterval):
|
||||
}
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
|
||||
if err != nil {
|
||||
// Treat polling failures as transient so short-lived backend hiccups
|
||||
// do not immediately fail an otherwise healthy export task.
|
||||
lastPollErr = err
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
|
||||
continue
|
||||
}
|
||||
lastStatus = status
|
||||
hasObservedStatus = true
|
||||
|
||||
if status.Ready() {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
|
||||
fileName := preferredFileName
|
||||
if fileName == "" {
|
||||
fileName = status.FileName
|
||||
}
|
||||
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
|
||||
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
|
||||
if err != nil {
|
||||
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
|
||||
hint := fmt.Sprintf(
|
||||
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
|
||||
ticket,
|
||||
status.FileToken,
|
||||
recoveryCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(err, hint)
|
||||
}
|
||||
out["ticket"] = ticket
|
||||
out["doc_type"] = spec.DocType
|
||||
out["file_extension"] = spec.FileExtension
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if status.Failed() {
|
||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||
if msg == "" {
|
||||
msg = status.StatusLabel()
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
|
||||
}
|
||||
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
},
|
||||
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
|
||||
if !hasObservedStatus && lastPollErr != nil {
|
||||
hint := fmt.Sprintf(
|
||||
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
|
||||
ticket,
|
||||
nextCommand,
|
||||
)
|
||||
return appendDriveExportRecoveryHint(lastPollErr, hint)
|
||||
}
|
||||
|
||||
failed := false
|
||||
var jobStatus interface{}
|
||||
jobStatusLabel := "unknown"
|
||||
if hasObservedStatus {
|
||||
failed = lastStatus.Failed()
|
||||
jobStatus = lastStatus.JobStatus
|
||||
jobStatusLabel = lastStatus.StatusLabel()
|
||||
}
|
||||
// Return the last observed status so callers can resume from a known task
|
||||
// state instead of losing all progress information on timeout.
|
||||
result := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"token": spec.Token,
|
||||
"doc_type": spec.DocType,
|
||||
"file_extension": spec.FileExtension,
|
||||
"ready": false,
|
||||
"failed": failed,
|
||||
"job_status": jobStatus,
|
||||
"job_status_label": jobStatusLabel,
|
||||
"timed_out": true,
|
||||
"next_command": nextCommand,
|
||||
}
|
||||
if preferredFileName != "" {
|
||||
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package drive
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -497,6 +498,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
|
||||
// explicit empty --output-dir must still download to the current directory
|
||||
// (normalized to "."), not trigger the export-only no-download path that the
|
||||
// shared RunExport core uses for sheets +workbook-export.
|
||||
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/export_tasks",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/tk_e",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"job_status": 0, "file_token": "box_e", "file_name": "report",
|
||||
"file_extension": "pdf", "type": "docx", "file_size": 3,
|
||||
},
|
||||
}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
|
||||
Status: 200,
|
||||
RawBody: []byte("pdf"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"application/pdf"},
|
||||
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
|
||||
driveExportPollAttempts, driveExportPollInterval = 1, 0
|
||||
t.Cleanup(func() {
|
||||
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveExport, []string{
|
||||
"+export",
|
||||
"--token", "docx123",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--output-dir", "",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Empty --output-dir must still write to cwd, not skip the download.
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
|
||||
if err != nil {
|
||||
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
|
||||
}
|
||||
if string(data) != "pdf" {
|
||||
t.Fatalf("downloaded content = %q", string(data))
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"downloaded": false`) {
|
||||
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1034,3 +1101,37 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
|
||||
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
|
||||
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
|
||||
// directly so an untyped context.Canceled would escape as a plain string at
|
||||
// the command layer, bypassing the typed-error contract.
|
||||
func TestWrapExportContextErr(t *testing.T) {
|
||||
if err := wrapExportContextErr(nil); err != nil {
|
||||
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
|
||||
}
|
||||
|
||||
cancelled := wrapExportContextErr(context.Canceled)
|
||||
var netErrCancel *errs.NetworkError
|
||||
if !errors.As(cancelled, &netErrCancel) {
|
||||
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
|
||||
}
|
||||
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(cancelled, context.Canceled) {
|
||||
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
|
||||
}
|
||||
|
||||
deadline := wrapExportContextErr(context.DeadlineExceeded)
|
||||
var netErrDeadline *errs.NetworkError
|
||||
if !errors.As(deadline, &netErrDeadline) {
|
||||
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
|
||||
}
|
||||
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
if !errors.Is(deadline, context.DeadlineExceeded) {
|
||||
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,139 +28,171 @@ var DriveImport = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveImportSpec(driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
})
|
||||
return ValidateImport(importParamsFromFlags(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := driveImportSpec{
|
||||
FilePath: runtime.Str("file"),
|
||||
DocType: strings.ToLower(runtime.Str("type")),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
|
||||
},
|
||||
}
|
||||
|
||||
// ImportParams holds the user-facing inputs for an import flow, decoupled from
|
||||
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
|
||||
// the drive import implementation without taking a dependency on a --type flag.
|
||||
type ImportParams struct {
|
||||
File string
|
||||
DocType string
|
||||
FolderToken string
|
||||
Name string
|
||||
TargetToken string
|
||||
}
|
||||
|
||||
func (p ImportParams) spec() driveImportSpec {
|
||||
return driveImportSpec{
|
||||
FilePath: p.File,
|
||||
DocType: strings.ToLower(p.DocType),
|
||||
FolderToken: p.FolderToken,
|
||||
Name: p.Name,
|
||||
TargetToken: p.TargetToken,
|
||||
}
|
||||
}
|
||||
|
||||
// importParamsFromFlags reads the standard drive +import flag set.
|
||||
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
|
||||
return ImportParams{
|
||||
File: runtime.Str("file"),
|
||||
DocType: runtime.Str("type"),
|
||||
FolderToken: runtime.Str("folder-token"),
|
||||
Name: runtime.Str("name"),
|
||||
TargetToken: runtime.Str("target-token"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImport runs the CLI-level compatibility checks for an import.
|
||||
func ValidateImport(p ImportParams) error {
|
||||
return validateDriveImportSpec(p.spec())
|
||||
}
|
||||
|
||||
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
|
||||
// an import without performing any network or file I/O beyond a local stat.
|
||||
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
|
||||
spec := p.spec()
|
||||
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if valErr := validateDriveImportSpec(spec); valErr != nil {
|
||||
return common.NewDryRunAPI().Set("error", valErr.Error())
|
||||
}
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
|
||||
|
||||
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
|
||||
appendDriveImportUploadDryRun(dry, spec, fileSize)
|
||||
|
||||
dry.POST("/open-apis/drive/v1/import_tasks").
|
||||
Desc("[2] Create import task").
|
||||
Body(spec.CreateTaskBody("<file_token>"))
|
||||
|
||||
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
|
||||
Desc("[3] Poll import task result").
|
||||
Set("ticket", "<ticket>")
|
||||
if runtime.IsBot() {
|
||||
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
|
||||
}
|
||||
|
||||
return dry
|
||||
}
|
||||
|
||||
// RunImport executes the full import flow: upload media -> create import task ->
|
||||
// bounded poll, then writes the result envelope to the runtime output. It is
|
||||
// the shared core behind both drive +import and sheets +workbook-import.
|
||||
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
|
||||
spec := p.spec()
|
||||
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 1: Upload file as media
|
||||
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
|
||||
if uploadErr != nil {
|
||||
return uploadErr
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
|
||||
|
||||
// Step 2: Create import task
|
||||
ticket, err := createDriveImportTask(runtime, spec, fileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Poll task
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
|
||||
|
||||
status, ready, err := pollDriveImportTask(runtime, ticket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Some intermediate responses omit the final type, so fall back to the
|
||||
// requested type to keep the output shape stable.
|
||||
resultType := status.DocType
|
||||
if resultType == "" {
|
||||
resultType = spec.DocType
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"ticket": ticket,
|
||||
"type": resultType,
|
||||
"ready": ready,
|
||||
"job_status": status.JobStatus,
|
||||
"job_status_label": status.StatusLabel(),
|
||||
}
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
}
|
||||
if status.Extra != nil {
|
||||
out["extra"] = status.Extra
|
||||
}
|
||||
if !ready {
|
||||
nextCommand := driveImportTaskResultCommand(ticket)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
|
||||
out["timed_out"] = true
|
||||
out["next_command"] = nextCommand
|
||||
}
|
||||
if ready {
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
|
||||
// Keep dry-run and execution aligned on path normalization, file existence,
|
||||
// and format-specific size limits before planning the upload path.
|
||||
|
||||
@@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"csv": {"sheet", "bitable"},
|
||||
"base": {"bitable"},
|
||||
"pptx": {"slides"},
|
||||
"pdf": {"slides"},
|
||||
}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
@@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "pptx":
|
||||
case "pptx", "pdf":
|
||||
return driveImport500MBFileSizeLimit, true
|
||||
case "txt", "md", "mark", "markdown", "html", "xls", "base":
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
@@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
|
||||
func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
ext := spec.FileExtension()
|
||||
if ext == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
@@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
|
||||
supportedTypes, ok := driveImportExtToDocTypes[ext]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
|
||||
}
|
||||
|
||||
typeAllowed := false
|
||||
@@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
|
||||
case "base":
|
||||
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
|
||||
case "pptx":
|
||||
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
|
||||
case "pptx", "pdf":
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
|
||||
default:
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
name: "pptx slides ok",
|
||||
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
|
||||
},
|
||||
{
|
||||
name: "pdf slides ok",
|
||||
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
|
||||
},
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
|
||||
@@ -51,6 +55,11 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
|
||||
wantErr: ".pptx files can only be imported as 'slides'",
|
||||
},
|
||||
{
|
||||
name: "pdf non slides rejected",
|
||||
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
|
||||
wantErr: ".pdf files can only be imported as 'slides'",
|
||||
},
|
||||
{
|
||||
name: "unknown extension rejected",
|
||||
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
|
||||
@@ -138,6 +147,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "pdf exceeds 500mb limit",
|
||||
filePath: "./deck.pdf",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 500.0 MB import limit for .pdf",
|
||||
},
|
||||
{
|
||||
name: "pdf within 500mb limit",
|
||||
filePath: "./deck.pdf",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "base exceeds 20mb limit",
|
||||
filePath: "./snapshot.base",
|
||||
|
||||
686
shortcuts/drive/drive_member_add.go
Normal file
686
shortcuts/drive/drive_member_add.go
Normal file
@@ -0,0 +1,686 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// driveMemberAddIDTypes covers every user-facing --member-type value accepted
|
||||
// by the shortcut. Some values are normalized before hitting the API.
|
||||
var driveMemberAddIDTypes = []string{
|
||||
"email", "openid", "unionid", "openchat", "opendepartmentid",
|
||||
"groupid", "appid", "wikispaceid",
|
||||
}
|
||||
|
||||
var driveMemberAddPerms = []string{"view", "edit", "full_access"}
|
||||
var driveMemberAddPermTypes = []string{"container", "single_page"}
|
||||
var driveMemberAddWikiSpaceMemberKinds = []string{"wiki_space_member", "wiki_space_viewer", "wiki_space_editor"}
|
||||
|
||||
// driveMemberAddPrefixToType maps ID prefixes to their expected member_type
|
||||
// for conflict validation when --member-type is provided explicitly.
|
||||
var driveMemberAddPrefixToType = map[string]string{
|
||||
"ou_": "openid",
|
||||
"on_": "unionid",
|
||||
"oc_": "openchat",
|
||||
"od_": "opendepartmentid",
|
||||
}
|
||||
|
||||
var driveMemberAddURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
var driveMemberAddResourceTypes = []string{"docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes"}
|
||||
|
||||
const driveMemberAddBatchLimit = 10
|
||||
|
||||
// DriveMemberAdd adds a collaborator/member permission to a Drive resource.
|
||||
var DriveMemberAdd = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-add",
|
||||
Description: "Add a collaborator/member permission to a Drive document, file, folder, or wiki node",
|
||||
Risk: "high-risk-write",
|
||||
Scopes: []string{"docs:permission.member:create"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target token or document URL; type is auto-inferred from URL path when omitted", Required: true},
|
||||
{Name: "type", Desc: "target resource type; required when --token is a bare token"},
|
||||
{Name: "member-id", Desc: "collaborator ID; comma-separated for batch (max 10). Interpretation is decided by --member-type", Required: true},
|
||||
{Name: "member-type", Desc: "ID type for --member-id; supported: email|openid|unionid|openchat|opendepartmentid|groupid|appid|wikispaceid", Required: true},
|
||||
{Name: "member-kind", Desc: "request body type when --member-type=wikispaceid; one of wiki_space_member|wiki_space_viewer|wiki_space_editor"},
|
||||
{Name: "perm", Desc: "permission role to grant; defaults to view"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope; defaults to container; rejected for non-wiki types"},
|
||||
{Name: "need-notification", Type: "bool", Desc: "send an in-app notification after the grant (user identity only)"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Resource type is auto-inferred from URL paths; pass --type when --token is a bare token.",
|
||||
"Supported --member-type values: email, openid, unionid, openchat, opendepartmentid, groupid, appid, wikispaceid.",
|
||||
"When --member-type=wikispaceid, pass --member-kind wiki_space_member, wiki_space_viewer, or wiki_space_editor.",
|
||||
"--member-type is required; if the ID prefix conflicts with --member-type (e.g. ou_xxx with email), the command rejects it.",
|
||||
"--perm defaults to view (safest); use --dry-run first when granting edit or full_access.",
|
||||
"For wiki nodes, --perm-type defaults to container (current page and sub-pages), except --member-type=wikispaceid where --member-kind provides the wiki-space role.",
|
||||
"Department collaborator (--member-type=opendepartmentid) requires --as user; bot identity is not supported for department authorization.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberAddSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberAddSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveMemberAddDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberAddSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(spec.MemberIDs) == 1 {
|
||||
return executeDriveMemberAddSingle(runtime, spec)
|
||||
}
|
||||
return executeDriveMemberAddBatch(runtime, spec)
|
||||
},
|
||||
}
|
||||
|
||||
// driveMemberAddSpec is the normalized request model shared by Validate,
|
||||
// DryRun, Execute, and output shaping so they all observe the same defaults.
|
||||
type driveMemberAddSpec struct {
|
||||
Token string
|
||||
ResourceType string
|
||||
MemberIDs []string
|
||||
MemberType string
|
||||
// MemberKind is the explicit --member-kind value for member_type=wikispaceid.
|
||||
MemberKind string
|
||||
Perm string
|
||||
PermType string
|
||||
NeedNotification bool
|
||||
NotificationSet bool
|
||||
}
|
||||
|
||||
// DryRunParams builds the preview query string while preserving the semantic
|
||||
// difference between an omitted notification flag and an explicit false.
|
||||
func (spec driveMemberAddSpec) DryRunParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": spec.ResourceType}
|
||||
if spec.NotificationSet {
|
||||
params["need_notification"] = spec.NeedNotification
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// APIQueryParams builds the query params for permission.members.create.
|
||||
func (spec driveMemberAddSpec) APIQueryParams() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": spec.ResourceType}
|
||||
if spec.NotificationSet {
|
||||
params["need_notification"] = strconv.FormatBool(spec.NeedNotification)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// buildMemberBody builds a single member object for the request body.
|
||||
func buildMemberBody(memberID, memberType, wikiSpaceMemberKind, perm, permType string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"member_id": memberID,
|
||||
"member_type": memberType,
|
||||
"perm": perm,
|
||||
}
|
||||
if bodyType := driveMemberAddBodyType(memberType, wikiSpaceMemberKind); bodyType != "" {
|
||||
body["type"] = bodyType
|
||||
}
|
||||
if permType != "" {
|
||||
body["perm_type"] = permType
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// readDriveMemberAddSpec parses runtime flags into a normalized request model,
|
||||
// applying inference, defaults, and cross-field validation in one place.
|
||||
func readDriveMemberAddSpec(runtime *common.RuntimeContext) (driveMemberAddSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberAddTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
|
||||
// Parse member-id: comma-separated for batch.
|
||||
rawMemberID := strings.TrimSpace(runtime.Str("member-id"))
|
||||
if rawMemberID == "" {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and cannot be blank").WithParam("--member-id")
|
||||
}
|
||||
memberIDs := splitAndTrimMembers(rawMemberID)
|
||||
if len(memberIDs) == 0 {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id is required and must contain at least one non-blank ID").WithParam("--member-id")
|
||||
}
|
||||
if len(memberIDs) > driveMemberAddBatchLimit {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-id accepts at most %d IDs, got %d", driveMemberAddBatchLimit, len(memberIDs)).WithParam("--member-id")
|
||||
}
|
||||
if duplicate, first, second, ok := firstDuplicateDriveMemberID(memberIDs); ok {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--member-id contains duplicate collaborator ID %q at positions %d and %d; remove duplicates before retrying",
|
||||
duplicate, first+1, second+1,
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
|
||||
memberType, err := resolveDriveMemberAddMemberType(memberIDs, runtime.Str("member-type"))
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
memberKind, err := resolveDriveMemberAddMemberKind(memberType, runtime.Str("member-kind"))
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
|
||||
// perm: default to view.
|
||||
perm, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm"), driveMemberAddPerms, "--perm")
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
if perm == "" {
|
||||
perm = "view"
|
||||
}
|
||||
|
||||
// perm-type: only meaningful for wiki; default container except for wiki-space collaborators.
|
||||
permType, err := normalizeDriveMemberAddEnumValue(runtime.Str("perm-type"), driveMemberAddPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return driveMemberAddSpec{}, err
|
||||
}
|
||||
if resourceType == "wiki" && memberType == "wikispaceid" {
|
||||
if runtime.Changed("perm-type") {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--perm-type is not supported when --member-type=wikispaceid; use --member-kind wiki_space_member|wiki_space_viewer|wiki_space_editor to set the wiki-space role",
|
||||
).WithParam("--perm-type")
|
||||
}
|
||||
permType = ""
|
||||
} else if resourceType == "wiki" && permType == "" {
|
||||
permType = driveMemberAddDefaultPermType(resourceType)
|
||||
} else if resourceType != "wiki" && runtime.Changed("perm-type") {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
} else if resourceType != "wiki" {
|
||||
permType = ""
|
||||
}
|
||||
|
||||
spec := driveMemberAddSpec{
|
||||
Token: token,
|
||||
ResourceType: resourceType,
|
||||
MemberIDs: memberIDs,
|
||||
MemberType: memberType,
|
||||
MemberKind: memberKind,
|
||||
Perm: perm,
|
||||
PermType: permType,
|
||||
NeedNotification: runtime.Bool("need-notification"),
|
||||
NotificationSet: runtime.Changed("need-notification"),
|
||||
}
|
||||
if runtime.As().IsBot() && spec.NotificationSet {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--need-notification is only valid with --as user; omit it when using --as bot").WithParam("--need-notification")
|
||||
}
|
||||
if runtime.As().IsBot() && spec.MemberType == "opendepartmentid" {
|
||||
return driveMemberAddSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type=opendepartmentid requires --as user; bot identity does not support adding department collaborators").WithParam("--member-type")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// resolveDriveMemberAddTarget extracts (token, type) from a user-supplied
|
||||
// --token value that may be either a bare token or a full resource URL, plus an
|
||||
// optional explicit --type. Explicit --type wins over URL inference.
|
||||
func resolveDriveMemberAddTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
explicitType = strings.ToLower(strings.TrimSpace(explicitType))
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
urlToken, urlType, ok := parseDriveMemberAddResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unsupported URL path %q: expected one of %s followed by a token",
|
||||
parsed.Path, strings.Join(driveMemberAddSupportedURLPaths(), ", "),
|
||||
).WithParam("--token")
|
||||
}
|
||||
token = urlToken
|
||||
if explicitType == "" {
|
||||
resourceType = urlType
|
||||
}
|
||||
} else {
|
||||
token = raw
|
||||
}
|
||||
|
||||
if explicitType != "" {
|
||||
if !isSupportedDriveMemberAddResourceType(explicitType) {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--type must be one of: %s", strings.Join(driveMemberAddResourceTypes, ", ")).WithParam("--type")
|
||||
}
|
||||
resourceType = explicitType
|
||||
}
|
||||
|
||||
if resourceType == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberAddResourceTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
return token, resourceType, nil
|
||||
}
|
||||
|
||||
func driveMemberAddSupportedURLPaths() []string {
|
||||
paths := make([]string, 0, len(driveMemberAddURLPathToType))
|
||||
for _, mapping := range driveMemberAddURLPathToType {
|
||||
paths = append(paths, mapping.Prefix)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func parseDriveMemberAddResourceURLPath(path string) (token, resourceType string, ok bool) {
|
||||
for _, mapping := range driveMemberAddURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return token, mapping.Type, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func isSupportedDriveMemberAddResourceType(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case "docx", "doc", "sheet", "bitable", "file", "folder", "wiki", "mindnote", "slides", "minutes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveDriveMemberAddMemberType(memberIDs []string, explicit string) (string, error) {
|
||||
var err error
|
||||
explicit, err = normalizeDriveMemberAddEnumValue(explicit, driveMemberAddIDTypes, "--member-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if explicit == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-type is required; accepted values: %s", strings.Join(driveMemberAddIDTypes, ", ")).WithParam("--member-type")
|
||||
}
|
||||
for i, memberID := range memberIDs {
|
||||
if expected := inferMemberTypeFromID(memberID); expected != "" && expected != explicit {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"member-id[%d] %q prefix implies --member-type %s, but --member-type %s was provided; fix the ID or use the matching member type",
|
||||
i+1, memberID, expected, explicit,
|
||||
).WithParam("--member-id")
|
||||
}
|
||||
}
|
||||
return normalizeDriveMemberAddMemberType(explicit), nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberAddMemberKind(memberType, raw string) (string, error) {
|
||||
memberKind, err := normalizeDriveMemberAddEnumValue(raw, driveMemberAddWikiSpaceMemberKinds, "--member-kind")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if memberType == "wikispaceid" {
|
||||
if memberKind == "" {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--member-kind is required when --member-type=wikispaceid; allowed: %s",
|
||||
strings.Join(driveMemberAddWikiSpaceMemberKinds, ", "),
|
||||
).WithParam("--member-kind")
|
||||
}
|
||||
return memberKind, nil
|
||||
}
|
||||
if memberKind != "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--member-kind only applies when --member-type=wikispaceid").WithParam("--member-kind")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberAddMemberType(memberType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(memberType))
|
||||
}
|
||||
|
||||
func normalizeDriveMemberAddEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
// splitAndTrimMembers splits a comma-separated member-id string and trims whitespace.
|
||||
func splitAndTrimMembers(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
var result []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstDuplicateDriveMemberID(memberIDs []string) (duplicate string, first, second int, ok bool) {
|
||||
seen := make(map[string]int, len(memberIDs))
|
||||
for i, memberID := range memberIDs {
|
||||
if prev, exists := seen[memberID]; exists {
|
||||
return memberID, prev, i, true
|
||||
}
|
||||
seen[memberID] = i
|
||||
}
|
||||
return "", 0, 0, false
|
||||
}
|
||||
|
||||
// inferMemberTypeFromID returns the expected member_type for a member-id
|
||||
// based on its prefix, or "" if no prefix matches (e.g. groupid).
|
||||
func inferMemberTypeFromID(memberID string) string {
|
||||
memberID = strings.TrimSpace(memberID)
|
||||
if memberID == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(memberID, "@") {
|
||||
return "email"
|
||||
}
|
||||
for prefix, mtype := range driveMemberAddPrefixToType {
|
||||
if strings.HasPrefix(memberID, prefix) {
|
||||
return mtype
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// driveMemberAddDefaultPermType returns the default perm_type for a given
|
||||
// resource type. For wiki nodes, container is the default for regular
|
||||
// collaborators. Wiki-space collaborators omit perm_type because their role is
|
||||
// carried by the body type field.
|
||||
func driveMemberAddDefaultPermType(resourceType string) string {
|
||||
switch resourceType {
|
||||
case "wiki":
|
||||
return "container"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// inferDriveMemberKind derives the request-body collaborator kind from
|
||||
// member-type for all supported member-type values.
|
||||
func inferDriveMemberKind(memberType string) string {
|
||||
switch memberType {
|
||||
case "email", "openid", "unionid", "userid":
|
||||
return "user"
|
||||
case "openchat":
|
||||
return "chat"
|
||||
case "opendepartmentid":
|
||||
return "department"
|
||||
case "groupid":
|
||||
return "group"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberAddBodyType(memberType, wikiSpaceMemberKind string) string {
|
||||
if memberType == "wikispaceid" {
|
||||
return wikiSpaceMemberKind
|
||||
}
|
||||
return inferDriveMemberKind(memberType)
|
||||
}
|
||||
|
||||
// buildDriveMemberAddDryRun renders the exact request preview for --dry-run.
|
||||
func buildDriveMemberAddDryRun(spec driveMemberAddSpec) *common.DryRunAPI {
|
||||
if len(spec.MemberIDs) == 1 {
|
||||
body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Add Drive collaborator/member permission").
|
||||
POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token))).
|
||||
Params(spec.DryRunParams()).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
members := buildDriveMemberAddMemberBodies(spec)
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Batch add Drive collaborator/member permissions").
|
||||
POST(fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token))).
|
||||
Params(spec.DryRunParams()).
|
||||
Body(map[string]interface{}{"members": members})
|
||||
}
|
||||
|
||||
// executeDriveMemberAddSingle calls the single-member create API.
|
||||
func executeDriveMemberAddSingle(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Adding Drive member %s (type=%s, perm=%s) to %s %s...\n",
|
||||
common.MaskToken(spec.MemberIDs[0]), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
|
||||
|
||||
body := buildMemberBody(spec.MemberIDs[0], spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(spec.Token)),
|
||||
spec.APIQueryParams(),
|
||||
body,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := driveMemberAddOutput(spec, spec.MemberIDs[0], common.GetMap(data, "member"))
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Added Drive member %s\n", common.MaskToken(common.GetString(out, "member_id")))
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// executeDriveMemberAddBatch calls the batch_create API. A successful HTTP/API
|
||||
// response is treated as complete only when the server returns every requested
|
||||
// member_id, regardless of response array order.
|
||||
func executeDriveMemberAddBatch(runtime *common.RuntimeContext, spec driveMemberAddSpec) error {
|
||||
members := buildDriveMemberAddMemberBodies(spec)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Adding %d Drive members (type=%s, perm=%s) to %s %s...\n",
|
||||
len(spec.MemberIDs), spec.MemberType, spec.Perm, spec.ResourceType, common.MaskToken(spec.Token))
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members/batch_create", validate.EncodePathSegment(spec.Token)),
|
||||
spec.APIQueryParams(),
|
||||
map[string]interface{}{"members": members},
|
||||
)
|
||||
if err != nil {
|
||||
return wrapDriveMemberAddBatchAPIError(err)
|
||||
}
|
||||
|
||||
result := buildDriveMemberAddBatchResult(spec, data)
|
||||
if common.GetBool(result, "partial") {
|
||||
return runtime.OutPartialFailure(result, nil)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Added %d Drive member(s)\n", result["succeeded_count"])
|
||||
runtime.Out(result, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
driveMemberAddInvalidParameterCode = 1063001
|
||||
driveMemberAddInvalidOperationCode = 1063003
|
||||
)
|
||||
|
||||
func wrapDriveMemberAddBatchAPIError(err error) error {
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
wrapped := *apiErr
|
||||
switch apiErr.Code {
|
||||
case driveMemberAddInvalidOperationCode:
|
||||
wrapped.Message = "Drive batch member add failed: one or more requested members may already be collaborators on this resource"
|
||||
wrapped.Hint = "For batch add, remove members that already have access (especially a bot/app being added again), then retry only the missing collaborators."
|
||||
case driveMemberAddInvalidParameterCode:
|
||||
wrapped.Message = "Drive batch member add failed: one or more requested members may be invalid for this resource or identity"
|
||||
wrapped.Hint = "Check whether each --member-id exists, belongs to the same tenant, and is visible to the current identity; remove invalid members and retry only the valid collaborators."
|
||||
default:
|
||||
return err
|
||||
}
|
||||
wrapped.Cause = err
|
||||
return &wrapped
|
||||
}
|
||||
|
||||
func buildDriveMemberAddMemberBodies(spec driveMemberAddSpec) []map[string]interface{} {
|
||||
members := make([]map[string]interface{}, len(spec.MemberIDs))
|
||||
for i, mid := range spec.MemberIDs {
|
||||
members[i] = buildMemberBody(mid, spec.MemberType, spec.MemberKind, spec.Perm, spec.PermType)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
func buildDriveMemberAddBatchResult(spec driveMemberAddSpec, data map[string]interface{}) map[string]interface{} {
|
||||
rawMembers, _ := data["members"].([]interface{})
|
||||
|
||||
// Build set of requested IDs for O(1) lookup.
|
||||
requestedSet := make(map[string]bool, len(spec.MemberIDs))
|
||||
for _, id := range spec.MemberIDs {
|
||||
requestedSet[id] = true
|
||||
}
|
||||
|
||||
// First pass: build returned map and results array.
|
||||
// Matching is done by member_id, not by array index, so the server may
|
||||
// return members in any order without causing false partial_failure.
|
||||
results := make([]map[string]interface{}, 0, len(rawMembers))
|
||||
succeededIDs := make(map[string]bool, len(rawMembers))
|
||||
var mismatched []map[string]interface{}
|
||||
|
||||
for _, raw := range rawMembers {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawMemberID := common.GetString(m, "member_id")
|
||||
|
||||
out := driveMemberAddOutputWithOptions(spec, "", m, false)
|
||||
results = append(results, out)
|
||||
|
||||
if rawMemberID != "" {
|
||||
if requestedSet[rawMemberID] {
|
||||
succeededIDs[rawMemberID] = true
|
||||
} else {
|
||||
mismatched = append(mismatched, map[string]interface{}{
|
||||
"returned": rawMemberID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: find requested IDs missing from the response.
|
||||
missing := make([]string, 0)
|
||||
for _, memberID := range spec.MemberIDs {
|
||||
if !succeededIDs[memberID] {
|
||||
missing = append(missing, memberID)
|
||||
}
|
||||
}
|
||||
|
||||
partial := len(results) != len(spec.MemberIDs) || len(missing) > 0 || len(mismatched) > 0
|
||||
result := map[string]interface{}{
|
||||
"resource_token": spec.Token,
|
||||
"resource_type": spec.ResourceType,
|
||||
"requested_count": len(spec.MemberIDs),
|
||||
"succeeded_count": len(succeededIDs),
|
||||
"partial": partial,
|
||||
"members": results,
|
||||
"missing_member_ids": missing,
|
||||
}
|
||||
if len(mismatched) > 0 {
|
||||
result["mismatched_member_ids"] = mismatched
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// driveMemberAddOutput flattens the server response into a stable envelope and
|
||||
// backfills fields from spec when the server omits them.
|
||||
func driveMemberAddOutput(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}) map[string]interface{} {
|
||||
return driveMemberAddOutputWithOptions(spec, fallbackMemberID, raw, true)
|
||||
}
|
||||
|
||||
func driveMemberAddOutputWithOptions(spec driveMemberAddSpec, fallbackMemberID string, raw map[string]interface{}, allowDefaultMemberID bool) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"resource_token": spec.Token,
|
||||
"resource_type": spec.ResourceType,
|
||||
}
|
||||
if raw != nil {
|
||||
for _, key := range []string{"member_id", "member_type", "perm", "type"} {
|
||||
if v, ok := raw[key]; ok {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
if spec.ResourceType == "wiki" {
|
||||
if v, ok := raw["perm_type"]; ok {
|
||||
out["perm_type"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if common.GetString(out, "member_id") == "" {
|
||||
if fallbackMemberID == "" && allowDefaultMemberID && len(spec.MemberIDs) > 0 {
|
||||
fallbackMemberID = spec.MemberIDs[0]
|
||||
}
|
||||
if fallbackMemberID != "" {
|
||||
out["member_id"] = fallbackMemberID
|
||||
}
|
||||
}
|
||||
if common.GetString(out, "member_type") == "" {
|
||||
out["member_type"] = spec.MemberType
|
||||
}
|
||||
if common.GetString(out, "perm") == "" {
|
||||
out["perm"] = spec.Perm
|
||||
}
|
||||
if spec.PermType != "" && common.GetString(out, "perm_type") == "" {
|
||||
out["perm_type"] = spec.PermType
|
||||
}
|
||||
if bodyType := driveMemberAddBodyType(spec.MemberType, spec.MemberKind); bodyType != "" && common.GetString(out, "type") == "" {
|
||||
out["type"] = bodyType
|
||||
}
|
||||
if t := common.GetString(out, "type"); t != "" {
|
||||
out["member_kind"] = t
|
||||
}
|
||||
delete(out, "type")
|
||||
return out
|
||||
}
|
||||
1507
shortcuts/drive/drive_member_add_test.go
Normal file
1507
shortcuts/drive/drive_member_add_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveSync,
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+sync",
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -246,8 +246,9 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
shortcutHelp: "Create a Lark document",
|
||||
visibleFlag: "--content",
|
||||
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
hiddenFlags: []string{"markdown", "folder-token", "wiki-node", "wiki-space"},
|
||||
contentHelp: []string{
|
||||
"--title",
|
||||
"AI agents MUST read",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
"before writing any --content payload",
|
||||
@@ -257,7 +258,7 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
|
||||
"MUST NOT grep/open local SKILL.md files",
|
||||
"use --help for the latest command flags",
|
||||
},
|
||||
unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
unwanted: []string{"--markdown", "--folder-token", "--wiki-node", "--wiki-space"},
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
|
||||
@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
|
||||
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
|
||||
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-show-gridline",
|
||||
sc: SheetShowGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+sheet-hide-gridline",
|
||||
sc: SheetHideGridline,
|
||||
args: []string{"--sheet-id", "sh1"},
|
||||
subInput: `{"sheet-id":"sh1"}`,
|
||||
},
|
||||
{
|
||||
shortcut: "+dropdown-set",
|
||||
sc: DropdownSet,
|
||||
@@ -432,12 +444,7 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
t, tc.shortcut,
|
||||
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
|
||||
)
|
||||
if standaloneErr == nil {
|
||||
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
|
||||
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
|
||||
}
|
||||
requireValidation(t, standaloneErr, tc.wantContains)
|
||||
|
||||
// Batch path: translate the matching sub-op. The translator wraps
|
||||
// the inner error with "operations[i] (<shortcut>): " — assert the
|
||||
@@ -451,17 +458,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, batchErr := translateBatchOp(rawOp, testToken, 0)
|
||||
if batchErr == nil {
|
||||
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(batchErr.Error(), tc.wantContains) {
|
||||
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
|
||||
}
|
||||
batchVE := requireValidation(t, batchErr, tc.wantContains)
|
||||
// And the wrap context must include the sub-op index + shortcut
|
||||
// name so error reports stay actionable in multi-op batches.
|
||||
wrapHint := "operations[0] (" + tc.subShortcut + "):"
|
||||
if !strings.Contains(batchErr.Error(), wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
|
||||
if !strings.Contains(batchVE.Message, wrapHint) {
|
||||
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -517,12 +519,7 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -580,12 +577,7 @@ func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
|
||||
}
|
||||
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -716,12 +708,7 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -782,12 +769,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
|
||||
"input": subInput,
|
||||
}
|
||||
_, err := translateBatchOp(rawOp, testToken, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantContains) {
|
||||
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
|
||||
}
|
||||
requireValidation(t, err, tc.wantContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,12 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
|
||||
}},
|
||||
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
|
||||
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
|
||||
}},
|
||||
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
|
||||
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
|
||||
}},
|
||||
|
||||
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
|
||||
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -37,18 +35,9 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
|
||||
|
||||
// Bare value naming an existing file → guarded with a fix-it hint.
|
||||
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
|
||||
if err == nil {
|
||||
t.Fatal("expected guard error when --csv names an existing file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
|
||||
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("problem = %+v, want validation/invalid_argument", p)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("guard error = %T, want *errs.ValidationError", err)
|
||||
ve := requireValidation(t, err, "existing file")
|
||||
if !strings.Contains(ve.Message, "@data.csv") {
|
||||
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
|
||||
}
|
||||
if ve.Param != "--csv" {
|
||||
t.Errorf("param = %q, want --csv", ve.Param)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -44,12 +43,7 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
"range": "A1:H17",
|
||||
})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
|
||||
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
|
||||
}
|
||||
requireValidation(t, err, "--start-cell and --range are mutually exclusive")
|
||||
}
|
||||
|
||||
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
|
||||
@@ -61,12 +55,7 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
|
||||
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
|
||||
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
|
||||
_, err := csvPutInput(fv, "tok", "sid", "")
|
||||
if err == nil {
|
||||
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
|
||||
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
|
||||
}
|
||||
requireValidation(t, err, "--start-cell or --range is required")
|
||||
}
|
||||
|
||||
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Insert position; appended to the end when omitted",
|
||||
"desc": "Insert position (0-based); appended to the end when omitted",
|
||||
"default": "-1"
|
||||
},
|
||||
{
|
||||
@@ -413,6 +413,86 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Sheet name (XOR with `--sheet-id`)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-create": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
@@ -431,22 +511,33 @@
|
||||
"desc": "Target folder token; placed at the drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"name": "values",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
|
||||
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "values",
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
|
||||
"desc": "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -502,7 +593,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Local save path; export is triggered but not downloaded when omitted"
|
||||
"desc": "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
@@ -513,6 +604,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+workbook-import": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "file",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Local file path (.xlsx / .xls / .csv)"
|
||||
},
|
||||
{
|
||||
"name": "folder-token",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Target folder token; imported to the cloud drive root when omitted"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+sheet-info": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
@@ -1082,9 +1199,8 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Safety cap; default 200000",
|
||||
"default": "200000",
|
||||
"hidden": true
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "skip-hidden",
|
||||
@@ -1192,9 +1308,8 @@
|
||||
"kind": "own",
|
||||
"type": "int",
|
||||
"required": "optional",
|
||||
"desc": "Safety cap; default 200000",
|
||||
"default": "200000",
|
||||
"hidden": true
|
||||
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
|
||||
"default": "500000"
|
||||
},
|
||||
{
|
||||
"name": "include-row-prefix",
|
||||
@@ -1212,19 +1327,65 @@
|
||||
"desc": "Skip hidden rows and columns; default `false`"
|
||||
},
|
||||
{
|
||||
"name": "rows-json",
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-get": {
|
||||
"risk": "read",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheet-id",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by id); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "sheet-name",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Read only this sheet (by name); omit to read all sheets"
|
||||
},
|
||||
{
|
||||
"name": "range",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
|
||||
},
|
||||
{
|
||||
"name": "no-header",
|
||||
"kind": "own",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
|
||||
"default": "false"
|
||||
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": "Print the request path and parameters without executing"
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1849,7 +2010,7 @@
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
|
||||
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
@@ -1880,6 +2041,54 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"+table-put": {
|
||||
"risk": "write",
|
||||
"flags": [
|
||||
{
|
||||
"name": "url",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
|
||||
},
|
||||
{
|
||||
"name": "spreadsheet-token",
|
||||
"kind": "public",
|
||||
"type": "string",
|
||||
"required": "xor",
|
||||
"desc": "Spreadsheet token to write into (XOR with `--url`)"
|
||||
},
|
||||
{
|
||||
"name": "sheets",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "required",
|
||||
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON: top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "styles",
|
||||
"kind": "own",
|
||||
"type": "string",
|
||||
"required": "optional",
|
||||
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
|
||||
"input": [
|
||||
"file",
|
||||
"stdin"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dry-run",
|
||||
"kind": "system",
|
||||
"type": "bool",
|
||||
"required": "optional",
|
||||
"desc": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"+cells-clear": {
|
||||
"risk": "high-risk-write",
|
||||
"flags": [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": "2",
|
||||
"schema_version": "3",
|
||||
"flags": {
|
||||
"+batch-update": {
|
||||
"operations": {
|
||||
@@ -44,6 +44,8 @@
|
||||
"+sheet-hide",
|
||||
"+sheet-unhide",
|
||||
"+sheet-set-tab-color",
|
||||
"+sheet-show-gridline",
|
||||
"+sheet-hide-gridline",
|
||||
"+chart-create",
|
||||
"+chart-update",
|
||||
"+chart-delete",
|
||||
@@ -454,7 +456,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"link": {
|
||||
"description": "超链接地址(仅 type='link' 时必填)",
|
||||
"description": "超链接地址(type='link' 时必填);@文档 mention(mention_type 非 0)时也必填,传文档 URL(如搜索结果里的文档链接),否则卡片不可点。@人(mention_type=0)不需要传",
|
||||
"type": "string"
|
||||
},
|
||||
"mention_token": {
|
||||
@@ -462,8 +464,21 @@
|
||||
"type": "string"
|
||||
},
|
||||
"mention_type": {
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)",
|
||||
"type": "number"
|
||||
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取:1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
|
||||
"type": "number",
|
||||
"enum": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
8,
|
||||
11,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
22,
|
||||
30,
|
||||
38
|
||||
]
|
||||
},
|
||||
"notify": {
|
||||
"description": "是否发送通知(仅 type='mention' 时可选,默认 true)",
|
||||
@@ -1730,11 +1745,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -1787,11 +1803,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+chart-update": {
|
||||
@@ -2769,11 +2781,12 @@
|
||||
},
|
||||
"aggregateType": {
|
||||
"type": "string",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
|
||||
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格;counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
|
||||
"enum": [
|
||||
"sum",
|
||||
"average",
|
||||
"count",
|
||||
"counta",
|
||||
"min",
|
||||
"max",
|
||||
"median"
|
||||
@@ -2826,11 +2839,7 @@
|
||||
"data"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"position",
|
||||
"size"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"+cond-format-create": {
|
||||
@@ -6249,6 +6258,744 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"+table-put": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"+workbook-create": {
|
||||
"sheets": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"columns",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
|
||||
},
|
||||
"start_cell": {
|
||||
"type": "string",
|
||||
"default": "A1",
|
||||
"description": "写入起点单元格(A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"overwrite",
|
||||
"append"
|
||||
],
|
||||
"default": "overwrite",
|
||||
"description": "overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头)。"
|
||||
},
|
||||
"header": {
|
||||
"type": "boolean",
|
||||
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false(避免在已有表头下重复);显式给值可覆盖。"
|
||||
},
|
||||
"allow_overwrite": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)。默认 true。"
|
||||
},
|
||||
"columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值(date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本),null 表示空单元格。",
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"null"
|
||||
],
|
||||
"description": "单元格值:date→ISO yyyy-mm-dd 字符串;number→数值(json.Number 精度保留);bool→布尔;string→文本;null→空单元格。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dtypes": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 object(string + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态)。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number(精度保留),`bool` / `boolean` → bool,`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date(默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`(数字样字符串如「00123」不会塌缩成数字)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "object",
|
||||
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等)。percent 列的数值尺度由调用方负责(0.0469 配 `0.00%` 显示 4.69%)。",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"styles": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"cell_merges": {
|
||||
"description": "单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"merge_type": {
|
||||
"enum": [
|
||||
"all",
|
||||
"rows",
|
||||
"columns"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cell_styles": {
|
||||
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"background_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"border_styles": {
|
||||
"type": "object",
|
||||
"description": "边框配置,结构同 +cells-set-style --border-styles。",
|
||||
"properties": {
|
||||
"bottom": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"left": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"right": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"top": {
|
||||
"properties": {
|
||||
"color": {
|
||||
"description": "边框颜色(十六进制,例如 \"#000000\")",
|
||||
"type": "string"
|
||||
},
|
||||
"style": {
|
||||
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
|
||||
"enum": [
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"double",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"weight": {
|
||||
"description": "边框粗细/线宽",
|
||||
"enum": [
|
||||
"thin",
|
||||
"medium",
|
||||
"thick"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"font_color": {
|
||||
"type": "string"
|
||||
},
|
||||
"font_line": {
|
||||
"enum": [
|
||||
"none",
|
||||
"underline",
|
||||
"line-through"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_size": {
|
||||
"type": "number"
|
||||
},
|
||||
"font_style": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"italic"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"font_weight": {
|
||||
"enum": [
|
||||
"normal",
|
||||
"bold"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"horizontal_alignment": {
|
||||
"enum": [
|
||||
"left",
|
||||
"center",
|
||||
"right"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"number_format": {
|
||||
"type": "string"
|
||||
},
|
||||
"range": {
|
||||
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
|
||||
"type": "string"
|
||||
},
|
||||
"vertical_alignment": {
|
||||
"enum": [
|
||||
"top",
|
||||
"middle",
|
||||
"bottom"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"word_wrap": {
|
||||
"enum": [
|
||||
"overflow",
|
||||
"auto-wrap",
|
||||
"word-clip"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"col_sizes": {
|
||||
"description": "列宽操作数组;range 使用列范围如 A:C,type 为 pixel/standard,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1(其 name 会被忽略)。",
|
||||
"type": "string"
|
||||
},
|
||||
"row_sizes": {
|
||||
"description": "行高操作数组;range 使用行范围如 1:3,type 为 pixel/standard/auto,pixel 需要 size。",
|
||||
"items": {
|
||||
"properties": {
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"pixel",
|
||||
"standard",
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"range",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
||||
@@ -47,19 +48,132 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
stdout, stderr, err := func() (string, string, error) {
|
||||
_, _, err := func() (string, string, error) {
|
||||
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
|
||||
reg.Register(stub)
|
||||
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
|
||||
err := parent.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}()
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
|
||||
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeServerError, "")
|
||||
if !strings.Contains(p.Message, "1310201") && !strings.Contains(p.Message, "not found") {
|
||||
t.Errorf("expected error code or message in problem; got message=%q", p.Message)
|
||||
}
|
||||
combined := stdout + stderr + err.Error()
|
||||
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
|
||||
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLResolvesToSheet covers the two-step wiki path: a /wiki/
|
||||
// URL is resolved via get_node to its spreadsheet obj_token, which then feeds
|
||||
// the tool invoke. The tool stub is keyed on the resolved obj_token, so the
|
||||
// test would fail if the node_token were used unresolved.
|
||||
func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": testToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tool := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","index":0}]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Fatalf("sheets len = %d, want 1; out=%s", len(sheets), out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
|
||||
// non-spreadsheet obj_type before any tool invoke.
|
||||
func TestExecute_WikiURLWrongObjType(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docABC",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
requireValidation(t, err, "obj_type")
|
||||
}
|
||||
|
||||
// TestExecute_WikiURLIncompleteNode treats an incomplete get_node response
|
||||
// (missing obj_type/obj_token) as an internal/server error, not a user --url
|
||||
// validation error.
|
||||
func TestExecute_WikiURLIncompleteNode(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := runShortcutWithStubs(t, WorkbookInfo,
|
||||
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
|
||||
if err == nil {
|
||||
t.Fatal("want error for incomplete get_node node data")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
t.Fatalf("incomplete-data error classified as validation (%v); want internal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_RangeMove_WikiURL guards the transformExecuteFn path: +range-move
|
||||
// and +range-copy use a named Execute helper (not an inline func), so they must
|
||||
// still resolve a /wiki/ URL to the backing spreadsheet token before calling
|
||||
// transform_range. The tool stub is keyed on the resolved obj_token, so an
|
||||
// unresolved node_token would miss it and fail this test.
|
||||
func TestExecute_RangeMove_WikiURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
getNode := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "sheet",
|
||||
"obj_token": testToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tool := toolOutputStub(testToken, "write", `{"updated_range":"A10:B11"}`)
|
||||
out, err := runShortcutWithStubs(t, RangeMove,
|
||||
[]string{
|
||||
"--url", "https://example.feishu.cn/wiki/wikTestNODE",
|
||||
"--sheet-id", testSheetID,
|
||||
"--source-range", "A1:B2",
|
||||
"--target-range", "A10",
|
||||
}, getNode, tool)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,14 +479,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initial fill first reads the workbook structure to resolve the default
|
||||
// sheet's id (the create response doesn't echo it), then writes.
|
||||
// The write reads the workbook structure to resolve the default sheet's id
|
||||
// (the create response doesn't echo it). lookupFirstSheetID and
|
||||
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
|
||||
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
|
||||
// so it's adopted in place (no rename).
|
||||
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
||||
structure.Reusable = true
|
||||
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
||||
"--title", "Sales",
|
||||
"--headers", `["Name","Score"]`,
|
||||
"--values", `[["alice",95]]`,
|
||||
"--values", `[["Name","Score"],["alice",95]]`,
|
||||
}, create, structure, fill)
|
||||
if err != nil {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
@@ -382,8 +499,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
||||
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
||||
}
|
||||
if data["initial_fill"] == nil {
|
||||
t.Errorf("initial_fill missing in envelope")
|
||||
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
|
||||
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
|
||||
}
|
||||
// The fill must target the resolved first sheet, not an empty selector.
|
||||
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
||||
@@ -393,14 +510,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
||||
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
||||
// the initial fill (no structure/fill calls fire) and finish with the
|
||||
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
||||
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
|
||||
// (no structure/fill calls fire) and finish with the spreadsheet created but no
|
||||
// sheets summary — never panic on a nil payload.
|
||||
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, flag, val string }{
|
||||
{"empty values", "--values", "[]"},
|
||||
{"empty headers", "--headers", "[]"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -421,8 +537,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
||||
}
|
||||
data := decodeEnvelopeData(t, out)
|
||||
if data["initial_fill"] != nil {
|
||||
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
||||
if data["sheets"] != nil {
|
||||
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
|
||||
}
|
||||
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
||||
@@ -431,10 +547,14 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
|
||||
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-state
|
||||
// contract: when the spreadsheet is created but the follow-up fill can't resolve
|
||||
// its first sheet, the error must be structured and retain spreadsheet_token so
|
||||
// the caller can recover instead of orphaning the new workbook.
|
||||
// its first sheet, the result lands on stdout as an ok:false envelope carrying
|
||||
// spreadsheet_token + reason + a structured cause field, and the process exits
|
||||
// with the bare partial-failure signal — matching +table-put's tablePutPartial
|
||||
// shape so agents see one consistent "side effect landed but follow-up didn't"
|
||||
// contract across the sheets domain (instead of the old failed_precondition
|
||||
// stderr envelope).
|
||||
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
create := &httpmock.Stub{
|
||||
@@ -448,33 +568,41 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
||||
},
|
||||
}
|
||||
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
|
||||
// spreadsheet already exists — exercising the partial-success path.
|
||||
// spreadsheet already exists — exercising the partial-state path.
|
||||
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
|
||||
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
|
||||
if err == nil {
|
||||
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
|
||||
t.Fatalf("expected partial-failure exit signal; got nil. out=%s", out)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError exit signal; got %T %v", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype)
|
||||
|
||||
var env map[string]interface{}
|
||||
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", jerr, out)
|
||||
}
|
||||
if !strings.Contains(p.Message, "shtNEW") {
|
||||
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
|
||||
if ok, _ := env["ok"].(bool); ok {
|
||||
t.Errorf("partial-state envelope must be ok:false; got out=%s", out)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "spreadsheet_token") {
|
||||
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got := data["spreadsheet_token"]; got != "shtNEW" {
|
||||
t.Errorf("spreadsheet_token = %v, want shtNEW (recovery requires the token to be in the envelope)", got)
|
||||
}
|
||||
// The underlying fill failure is preserved as the cause so its subtype and
|
||||
// log_id stay diagnosable rather than being flattened into the message.
|
||||
inner := errors.Unwrap(err)
|
||||
if inner == nil {
|
||||
t.Fatalf("expected the underlying fill failure preserved as the cause")
|
||||
reason, _ := data["reason"].(string)
|
||||
if !strings.Contains(reason, "shtNEW") {
|
||||
t.Errorf("reason = %q, want the spreadsheet token named for recovery", reason)
|
||||
}
|
||||
if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner)
|
||||
hint, _ := data["hint"].(string)
|
||||
if !strings.Contains(hint, "spreadsheet_token") {
|
||||
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", hint)
|
||||
}
|
||||
// The underlying fill failure's typed shape is flattened into the cause
|
||||
// field so the inner subtype stays diagnosable from the JSON envelope alone.
|
||||
cause, _ := data["cause"].(map[string]interface{})
|
||||
if got := cause["subtype"]; got != string(errs.SubtypeInvalidResponse) {
|
||||
t.Errorf("cause.subtype = %v, want the underlying invalid_response subtype", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,3 +80,28 @@ func flagsFor(command string) []common.Flag {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flagAcceptsStdin reports whether the (command, flag) pair declares stdin as
|
||||
// an input source in flag-defs.json. Used to decide whether an "invalid JSON"
|
||||
// error should also steer the caller toward stdin. It runs on an error path,
|
||||
// so it returns false for an unknown command/flag rather than panicking the
|
||||
// way flagsFor does.
|
||||
func flagAcceptsStdin(command, name string) bool {
|
||||
defs, _ := loadFlagDefs()
|
||||
spec, ok := defs[command]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, df := range spec.Flags {
|
||||
if df.Name != name {
|
||||
continue
|
||||
}
|
||||
for _, in := range df.Input {
|
||||
if in == common.Stdin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
@@ -305,10 +305,9 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
|
||||
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
|
||||
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
|
||||
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
|
||||
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
|
||||
},
|
||||
},
|
||||
@@ -320,7 +319,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
|
||||
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
|
||||
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -766,7 +765,7 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
|
||||
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
|
||||
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
|
||||
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
@@ -793,6 +792,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-hide-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
@@ -839,6 +848,16 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-show-gridline": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
|
||||
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+sheet-unhide": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
@@ -895,13 +914,36 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-get": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
|
||||
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
|
||||
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
|
||||
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+table-put": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON: top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-create": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
|
||||
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
|
||||
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
|
||||
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
|
||||
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
@@ -912,10 +954,18 @@ var flagDefs = map[string]commandDef{
|
||||
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
|
||||
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
|
||||
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
|
||||
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."},
|
||||
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
|
||||
},
|
||||
},
|
||||
"+workbook-import": {
|
||||
Risk: "write",
|
||||
Flags: []flagDef{
|
||||
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
|
||||
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
|
||||
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
|
||||
},
|
||||
},
|
||||
"+workbook-info": {
|
||||
Risk: "read",
|
||||
Flags: []flagDef{
|
||||
|
||||
@@ -65,9 +65,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
|
||||
if url == nil || url.Required {
|
||||
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
|
||||
}
|
||||
// hidden + int default
|
||||
// visible + int default
|
||||
cap := byName("+cells-get", "max-chars")
|
||||
if cap == nil || !cap.Hidden || cap.Default != "200000" {
|
||||
if cap == nil || cap.Hidden || cap.Default != "500000" {
|
||||
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
|
||||
}
|
||||
// input sources
|
||||
@@ -140,3 +140,24 @@ func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagAcceptsStdin verifies the stdin-capability probe that decides whether
|
||||
// an "invalid JSON" error should also steer the caller toward stdin: a composite
|
||||
// flag (cells) accepts stdin, a plain locator (spreadsheet-token) does not, and
|
||||
// an unknown command/flag returns false without panicking (it runs on an error
|
||||
// path, unlike flagsFor).
|
||||
func TestFlagAcceptsStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !flagAcceptsStdin("+cells-set", "cells") {
|
||||
t.Error("+cells-set --cells should accept stdin")
|
||||
}
|
||||
if flagAcceptsStdin("+cells-set", "spreadsheet-token") {
|
||||
t.Error("--spreadsheet-token should not accept stdin")
|
||||
}
|
||||
if flagAcceptsStdin("+nope", "cells") {
|
||||
t.Error("unknown command should be false (and must not panic)")
|
||||
}
|
||||
if flagAcceptsStdin("+cells-set", "nope") {
|
||||
t.Error("unknown flag should be false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ─── --print-schema runtime introspection ─────────────────────────────
|
||||
@@ -91,7 +93,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
}
|
||||
entry, ok := idx.Flags[command]
|
||||
if !ok || len(entry) == 0 {
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
|
||||
}
|
||||
if flagName == "" {
|
||||
flags := make([]string, 0, len(entry))
|
||||
@@ -112,7 +114,9 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
|
||||
flags = append(flags, f)
|
||||
}
|
||||
sort.Strings(flags)
|
||||
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
|
||||
WithParam("--flag-name")
|
||||
}
|
||||
// Reformat for readability — schema files store compact JSON.
|
||||
var pretty interface{}
|
||||
|
||||
@@ -84,12 +84,12 @@ func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
|
||||
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown flag, got nil")
|
||||
ve := requireValidation(t, err, "+chart-create")
|
||||
if !strings.Contains(ve.Message, "properties") {
|
||||
t.Errorf("message should list available flags; got %q", ve.Message)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
|
||||
t.Errorf("error should mention shortcut + available flags; got %q", msg)
|
||||
if ve.Param != "--flag-name" {
|
||||
t.Errorf("param = %q, want --flag-name", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user