mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
6 Commits
feat/apps-
...
feat/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c7f9f390 | ||
|
|
3f993ea772 | ||
|
|
461b4a7e80 | ||
|
|
d6b235aaa2 | ||
|
|
d6dfd1e043 | ||
|
|
3a33794aec |
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@@ -5,7 +5,6 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -71,7 +70,6 @@ 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
|
||||
@@ -89,23 +87,6 @@ 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
|
||||
@@ -128,28 +109,8 @@ 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: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
run: make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -259,7 +220,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -280,7 +241,7 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -372,7 +333,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -384,7 +345,6 @@ 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
|
||||
@@ -401,7 +361,6 @@ 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
28
.github/workflows/comment-audit.yml
vendored
@@ -1,28 +0,0 @@
|
||||
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,44 +88,31 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
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) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.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;
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
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 {
|
||||
if (candidatePRs.length !== 1) {
|
||||
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({
|
||||
@@ -134,11 +121,6 @@ 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");
|
||||
@@ -317,44 +299,31 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
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) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.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;
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "all",
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
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 {
|
||||
if (candidatePRs.length !== 1) {
|
||||
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({
|
||||
@@ -363,16 +332,6 @@ 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");
|
||||
@@ -430,10 +389,6 @@ 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,11 +7,6 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -51,4 +46,3 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,35 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
@@ -1265,7 +1236,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
|
||||
5
Makefile
5
Makefile
@@ -12,7 +12,6 @@ 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
|
||||
|
||||
@@ -70,8 +69,7 @@ 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)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
@@ -91,7 +89,6 @@ 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
|
||||
|
||||
@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
@@ -260,6 +260,15 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_SlidesDoesNotAdvertiseScreenshotScope(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"slides"}, "user", "")
|
||||
for _, scope := range scopes {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("slides domain scopes must not advertise allowlist-gated screenshot scope: %#v", scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
nameSet := make(map[string]bool)
|
||||
|
||||
@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,34 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
// 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,21 +27,6 @@ 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 {
|
||||
|
||||
@@ -7,7 +7,6 @@ package events
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||
// standard Lark V2 event envelope.
|
||||
type TaskUpdateUserAccessV2Data struct {
|
||||
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||
}
|
||||
|
||||
var taskUpdateUserAccessCommitTypes = []string{
|
||||
"task_create",
|
||||
"task_deleted",
|
||||
"task_summary_update",
|
||||
"task_desc_update",
|
||||
"task_assignees_update",
|
||||
"task_followers_update",
|
||||
"task_reminders_update",
|
||||
"task_start_due_update",
|
||||
"task_completed_update",
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||
|
||||
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"failed to subscribe task event",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
err error
|
||||
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.method = method
|
||||
s.path = path
|
||||
s.body = body
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||
rt := &stubAPIClient{}
|
||||
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||
}
|
||||
if rt.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||
}
|
||||
if rt.method != "POST" {
|
||||
t.Errorf("method = %q, want POST", rt.method)
|
||||
}
|
||||
if rt.path != taskSubscriptionPath {
|
||||
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||
}
|
||||
if rt.body != nil {
|
||||
t.Errorf("body = %#v, want nil", rt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||
rt := &stubAPIClient{err: wantErr}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||
cause := errors.New("connection reset")
|
||||
rt := &stubAPIClient{err: cause}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package task registers Task-domain EventKeys.
|
||||
package task
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||
|
||||
// Keys returns all Task-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeTaskUpdateUserAccessV2,
|
||||
DisplayName: "Task updated",
|
||||
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||
},
|
||||
PreConsume: taskSubscriptionPreConsume,
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||
SingleConsumer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Schema.Native is nil")
|
||||
}
|
||||
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Fatal("Native Task EventKey must not set Process")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume is nil")
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Fatal("SingleConsumer = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||
t.Errorf("Scopes = %#v", def.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
|
||||
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||
if got := taskGUID["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
|
||||
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||
items := eventTypes["items"].(map[string]interface{})
|
||||
rawEnum, ok := items["enum"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||
}
|
||||
got := make(map[string]bool, len(rawEnum))
|
||||
for _, v := range rawEnum {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||
if !got[want] {
|
||||
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeTaskUpdateUserAccessV2
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
@@ -131,3 +131,31 @@ 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,31 +29,3 @@ 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,22 +5,7 @@
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
const (
|
||||
spinnerInterval = 80 * time.Millisecond
|
||||
spinnerHideCursor = "\x1b[?25l"
|
||||
spinnerShowCursor = "\x1b[?25h"
|
||||
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
||||
)
|
||||
|
||||
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
||||
// until the returned stop() is called, e.g.:
|
||||
//
|
||||
// ⠹ Publishing dev → main... 3s
|
||||
//
|
||||
// It is meant for slow operations (long polls, first-time provisioning) so the
|
||||
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
||||
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
||||
//
|
||||
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
||||
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
||||
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
|
||||
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
|
||||
//
|
||||
// stop() clears the spinner line, restores the cursor, and blocks until the
|
||||
// render goroutine has finished — so callers can safely write the result to
|
||||
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
|
||||
// it is safe to call more than once (e.g. an explicit call plus a defer).
|
||||
func StartSpinner(w io.Writer, enabled bool, label string) func() {
|
||||
if !enabled || w == nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
start := time.Now()
|
||||
|
||||
go func() {
|
||||
defer close(finished)
|
||||
frame := 0
|
||||
fmt.Fprint(w, spinnerHideCursor)
|
||||
render := func() {
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
|
||||
frame = (frame + 1) % len(spinnerFrames)
|
||||
}
|
||||
render()
|
||||
ticker := time.NewTicker(spinnerInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
|
||||
return
|
||||
case <-ticker.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(done)
|
||||
<-finished // wait for the line to be cleared before returning
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
|
||||
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, false, "working")
|
||||
stop()
|
||||
stop() // idempotent
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
|
||||
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
|
||||
stop := StartSpinner(nil, true, "working")
|
||||
stop() // must not panic
|
||||
}
|
||||
|
||||
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
|
||||
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, true, "Publishing")
|
||||
// The goroutine renders the first frame synchronously before selecting on
|
||||
// the stop channel, so even an immediate stop() yields one full cycle.
|
||||
stop()
|
||||
stop() // idempotent, must not panic or double-write after finished
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, spinnerHideCursor) {
|
||||
t.Errorf("missing hide-cursor escape:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerFrames[0]) {
|
||||
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
|
||||
}
|
||||
if !strings.Contains(out, "Publishing...") {
|
||||
t.Errorf("missing label:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerClearLine) {
|
||||
t.Errorf("missing clear-line escape:\n%q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, spinnerShowCursor) {
|
||||
t.Errorf("must end by restoring the cursor:\n%q", out)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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,7 +13,6 @@ 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() {
|
||||
@@ -42,7 +41,6 @@ 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 {
|
||||
@@ -50,15 +48,6 @@ 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,37 +37,6 @@ 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,14 +56,6 @@ 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)
|
||||
@@ -80,15 +72,6 @@ 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 {
|
||||
@@ -98,21 +81,15 @@ func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision sema
|
||||
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 {
|
||||
return fmt.Errorf("write decision: %w", err)
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
|
||||
return fmt.Errorf("write markdown: %w", err)
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decisionExitCode(decision semantic.Decision) int {
|
||||
if decision.BlockMode && len(decision.Blockers) > 0 {
|
||||
if block && len(decision.Blockers) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
@@ -212,19 +211,7 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
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 {
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
@@ -241,71 +228,6 @@ 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", "")
|
||||
@@ -321,19 +243,7 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
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 {
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"error_hint",
|
||||
"default_output",
|
||||
"naming",
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
"skill_quality"
|
||||
],
|
||||
"rollout_groups": [
|
||||
{
|
||||
@@ -17,8 +16,7 @@
|
||||
},
|
||||
"categories": [
|
||||
"error_hint",
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
"skill_quality"
|
||||
],
|
||||
"owner": "cli-owner",
|
||||
"reason": "first semantic blocking rollout only affects changed facts"
|
||||
|
||||
@@ -13,15 +13,14 @@ 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"`
|
||||
PublicContent []PublicContentFact `json:"public_content,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"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type CommandFact struct {
|
||||
@@ -110,17 +109,6 @@ 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"`
|
||||
@@ -218,11 +206,6 @@ 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,7 +34,6 @@ 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 {
|
||||
@@ -44,10 +43,7 @@ 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 ||
|
||||
got.PublicContent[0].Rule != "public_content_generic_credential" {
|
||||
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
|
||||
t.Fatalf("facts lost gatekeeper fields: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,885 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,797 +0,0 @@
|
||||
// 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]) + "..."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
// 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,9 +174,8 @@ type materializedExample struct {
|
||||
}
|
||||
|
||||
type placeholderContext struct {
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagDefault string
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
}
|
||||
|
||||
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
|
||||
@@ -248,7 +247,6 @@ 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
|
||||
}
|
||||
@@ -311,17 +309,11 @@ 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 fakeValueFromContextHint(ctx)
|
||||
return fakeValueFromUsageHint(ctx.FlagUsage)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -344,26 +336,16 @@ 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"):
|
||||
@@ -395,98 +377,17 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
func fakeValueFromUsageHint(usage string) (string, bool) {
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
|
||||
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", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
|
||||
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -530,10 +431,6 @@ 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
|
||||
@@ -726,7 +623,6 @@ 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:] {
|
||||
@@ -746,23 +642,6 @@ 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,161 +305,6 @@ 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",
|
||||
@@ -755,51 +600,6 @@ 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,20 +15,18 @@ 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
|
||||
PublicContentMetadataPath string
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
|
||||
@@ -100,60 +98,9 @@ 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)
|
||||
|
||||
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
|
||||
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
|
||||
}
|
||||
|
||||
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
|
||||
@@ -220,9 +167,6 @@ 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,99 +189,6 @@ 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")
|
||||
@@ -599,7 +506,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd := exec.Command("git", append([]string{"-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", "public_content_leakage"},
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
},
|
||||
"severity": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
@@ -10,10 +10,9 @@ 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|public_content)\[(\d+)\]$`)
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
|
||||
|
||||
func Decide(f facts.Facts, r Review, p Policy) Decision {
|
||||
return DecideWithWaivers(f, r, p, Waivers{})
|
||||
@@ -173,16 +172,6 @@ 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
|
||||
}
|
||||
@@ -212,7 +201,7 @@ func validFinding(f Finding) bool {
|
||||
|
||||
func allowedCategory(category string) bool {
|
||||
switch category {
|
||||
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
|
||||
case "error_hint", "default_output", "naming", "skill_quality":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -258,12 +247,6 @@ 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
|
||||
}
|
||||
@@ -294,8 +277,6 @@ 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,7 +242,6 @@ 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
|
||||
@@ -252,7 +251,6 @@ 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{{
|
||||
@@ -270,59 +268,6 @@ 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, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs 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,9 +38,6 @@ 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,10 +23,7 @@ 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.",
|
||||
"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.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs 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", "public_content_leakage"},
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
RolloutGroups: []RolloutGroup{{
|
||||
ID: "all",
|
||||
Enforcement: "blocking",
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
Owner: "test",
|
||||
Reason: "default in-memory policy",
|
||||
}},
|
||||
|
||||
@@ -82,15 +82,6 @@ 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
|
||||
}
|
||||
@@ -204,7 +195,7 @@ func containsString(values []string, want string) bool {
|
||||
|
||||
func allowedFactKind(kind string) bool {
|
||||
switch kind {
|
||||
case "skill", "command", "error", "output", "public_content":
|
||||
case "skill", "command", "error", "output":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -81,30 +81,6 @@ 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,29 +13,27 @@ 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"`
|
||||
PublicContentLeakage []PublicContentInput `json:"public_content_leakage,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"`
|
||||
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"`
|
||||
PublicContent int `json:"public_content,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"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
type RuleSummaryItem struct {
|
||||
@@ -88,22 +86,6 @@ 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()
|
||||
@@ -122,17 +104,16 @@ 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(),
|
||||
PublicContentLeakage: selected.publicContentInputs(),
|
||||
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(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,11 +138,6 @@ 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 {
|
||||
@@ -181,31 +157,25 @@ 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
|
||||
publicContent []bool
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []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)),
|
||||
publicContent: make([]bool, len(f.PublicContent)),
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,8 +194,6 @@ 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
|
||||
}
|
||||
@@ -288,15 +256,6 @@ 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
|
||||
@@ -311,7 +270,6 @@ 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 {
|
||||
@@ -320,8 +278,7 @@ 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.publicContent, other.publicContent)
|
||||
selectionsIntersect(s.examples, other.examples)
|
||||
}
|
||||
|
||||
func (s *inputSelection) commandInputs() []CommandInput {
|
||||
@@ -394,16 +351,6 @@ 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{}
|
||||
@@ -455,10 +402,6 @@ 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
|
||||
@@ -491,8 +434,7 @@ 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" ||
|
||||
strings.HasPrefix(rule, "public_content_")
|
||||
rule == "no_bare_helper_error"
|
||||
}
|
||||
|
||||
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {
|
||||
|
||||
@@ -77,122 +77,6 @@ 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,10 +138,6 @@ 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,27 +21,24 @@ 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"+
|
||||
"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")
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\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) != 3 {
|
||||
if len(diags) != 0 || len(w.Items) != 2 {
|
||||
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",
|
||||
"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",
|
||||
"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",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
writeSemanticFile(t, repo, "waivers.txt", body)
|
||||
|
||||
@@ -5609,21 +5609,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.58",
|
||||
"version": "1.0.57",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -45,10 +45,6 @@ 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,25 +152,6 @@ 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({
|
||||
@@ -357,7 +338,6 @@ 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,42 +5,26 @@
|
||||
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 }
|
||||
/^ script-test:/ { exit }
|
||||
/^ deterministic-gate:/ { 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 }
|
||||
@@ -114,94 +98,13 @@ 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, 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"
|
||||
if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for deterministic-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -307,11 +210,6 @@ 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|public_content)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -230,20 +230,6 @@ 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;
|
||||
}
|
||||
@@ -859,10 +845,6 @@ 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,100 +202,6 @@ 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: [{
|
||||
@@ -709,35 +615,6 @@ 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({
|
||||
@@ -2346,8 +2223,8 @@ function fakeGithub(calls, options = {}) {
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
get: async () => {
|
||||
const pull = Array.isArray(options.currentPullRequests)
|
||||
get: async () => ({
|
||||
data: Array.isArray(options.currentPullRequests)
|
||||
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
|
||||
: options.currentPullRequest || {
|
||||
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
|
||||
@@ -2355,9 +2232,8 @@ 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,36 +229,6 @@ 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) {
|
||||
@@ -451,20 +421,6 @@ 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,43 +67,7 @@ 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 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 facts = Buffer.from('{"schema_version":1}\n');
|
||||
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
|
||||
fs.writeFileSync(zipPath, zip);
|
||||
|
||||
@@ -139,19 +103,6 @@ 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,10 +184,6 @@ 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"
|
||||
|
||||
@@ -216,12 +212,7 @@ 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" '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" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
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"
|
||||
@@ -269,7 +260,6 @@ 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"
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAppsAnalyticsEnv = "online"
|
||||
defaultAppsAnalyticsGranular = "day"
|
||||
analyticsListEndpoint = "query_analytics_data"
|
||||
)
|
||||
|
||||
// AppsAnalyticsList lists online app product analytics.
|
||||
var AppsAnalyticsList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+analytics-list",
|
||||
Description: "List online app user and page-view analytics",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +analytics-list --app-id <app_id> --analytics users --granularity week",
|
||||
"Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}},
|
||||
{Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"},
|
||||
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
|
||||
{Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"},
|
||||
{Name: "page", Desc: "frontend page or route filter"},
|
||||
{Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}},
|
||||
{Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, _, err := buildAnalyticsListBody(rctx)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, _, _, _ := buildAnalyticsListBody(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
POST(analyticsListPath(rctx.Str("app-id"))).
|
||||
Desc("List online app analytics").
|
||||
Body(body)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
body, types, labels, err := buildAnalyticsListBody(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := observabilitySeriesOutput{
|
||||
Items: normalizeAnalyticsSeries(data, types, labels),
|
||||
HasMore: false,
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
rows := observabilitySeriesRows(out.Items)
|
||||
sortObservabilityRowsDesc(rows, "timestamp_ns")
|
||||
rows = filterObservabilityRowsWithTime(rows, "timestamp_ns")
|
||||
appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func analyticsListPath(appID string) string {
|
||||
return appScopedPath(appID, analyticsListEndpoint)
|
||||
}
|
||||
|
||||
func buildAnalyticsListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) {
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
env = defaultAppsAnalyticsEnv
|
||||
}
|
||||
if err := validateObservabilityEnv(env); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type"))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until"))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity"))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if page := strings.TrimSpace(rctx.Str("page")); page != "" {
|
||||
filter["page"] = page
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"metric_types": types,
|
||||
"start_timestamp_ns": nsNumber(since),
|
||||
"end_timestamp_ns": nsNumber(until),
|
||||
"time_aggregation_unit": aggregation,
|
||||
"need_pack_lack_point": false,
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
return body, types, labels, nil
|
||||
}
|
||||
|
||||
func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) {
|
||||
name = strings.TrimSpace(strings.ToLower(name))
|
||||
series = strings.TrimSpace(strings.ToLower(series))
|
||||
deviceType = strings.TrimSpace(strings.ToLower(deviceType))
|
||||
filter := make(map[string]interface{})
|
||||
if deviceType != "" {
|
||||
switch deviceType {
|
||||
case "desktop", "mobile":
|
||||
filter["device_types"] = []string{deviceType}
|
||||
default:
|
||||
return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile")
|
||||
}
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "users":
|
||||
switch series {
|
||||
case "":
|
||||
return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil
|
||||
case "active", "active-users":
|
||||
return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil
|
||||
case "new", "new-users":
|
||||
return []string{"NEW_USER"}, []string{"new-users"}, filter, nil
|
||||
case "total", "total-users":
|
||||
return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil
|
||||
default:
|
||||
return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total")
|
||||
}
|
||||
case "page-view":
|
||||
switch series {
|
||||
case "", "all":
|
||||
return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil
|
||||
case "desktop", "desktop-view":
|
||||
if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil
|
||||
case "mobile", "mobile-view":
|
||||
if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil
|
||||
default:
|
||||
return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile")
|
||||
}
|
||||
default:
|
||||
return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view")
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error {
|
||||
if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType {
|
||||
return appsValidationParamError("--device-type", "--device-type conflicts with --series")
|
||||
}
|
||||
filter["device_types"] = []string{deviceType}
|
||||
return nil
|
||||
}
|
||||
|
||||
func analyticsGranularityForCLI(granularity string) (string, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(granularity)) {
|
||||
case "", "day":
|
||||
return "DAY", nil
|
||||
case "week":
|
||||
return "WEEK", nil
|
||||
case "month":
|
||||
return "MONTH", nil
|
||||
default:
|
||||
return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} {
|
||||
items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns")
|
||||
fillObservabilityZeroesWhenPartiallyPresent(items, labels)
|
||||
return items
|
||||
}
|
||||
|
||||
func analyticsSeriesSchema(labels []string) appsOutputSchema {
|
||||
columns := []appsOutputColumn{
|
||||
{Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")},
|
||||
}
|
||||
for _, label := range labels {
|
||||
columns = append(columns, appsOutputColumn{Key: label})
|
||||
}
|
||||
return appsOutputSchema{Columns: columns, Strict: true}
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "users",
|
||||
"--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z",
|
||||
"--granularity", "week", "--dry-run", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" {
|
||||
t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL)
|
||||
}
|
||||
body := env.API[0].Body
|
||||
if _, ok := body["start_timestamp_ns"]; !ok {
|
||||
t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body)
|
||||
}
|
||||
if _, ok := body["start_timestamp"]; ok {
|
||||
t.Fatalf("analytics should not use start_timestamp: %#v", body)
|
||||
}
|
||||
if body["time_aggregation_unit"] != "WEEK" {
|
||||
t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"])
|
||||
}
|
||||
if _, ok := body["app_env"]; ok {
|
||||
t.Fatalf("analytics OpenAPI body should not include app_env: %#v", body)
|
||||
}
|
||||
if _, ok := body["analytics_types"]; ok {
|
||||
t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body)
|
||||
}
|
||||
if body["need_pack_lack_point"] != false {
|
||||
t.Fatalf("need_pack_lack_point = %#v, want false", body["need_pack_lack_point"])
|
||||
}
|
||||
if _, ok := body["group_by"]; ok {
|
||||
t.Fatalf("group_by is intentionally unsupported for now: %#v", body)
|
||||
}
|
||||
if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 {
|
||||
t.Fatalf("metric_types = %#v", body["metric_types"])
|
||||
}
|
||||
if body["start_timestamp_ns"] != "1782208800000000000" ||
|
||||
body["end_timestamp_ns"] != "1782208860000000000" {
|
||||
t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "series",
|
||||
args: []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
|
||||
"--series", "desktop", "--page", "/home", "--dry-run", "--as", "user",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "device-type",
|
||||
args: []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
|
||||
"--device-type", "desktop", "--dry-run", "--as", "user",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, tc.args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
filter := env.API[0].Body["filter"].(map[string]interface{})
|
||||
deviceTypes := filter["device_types"].([]interface{})
|
||||
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
|
||||
t.Fatalf("device_types = %#v", deviceTypes)
|
||||
}
|
||||
if tc.name == "series" && filter["page"] != "/home" {
|
||||
t.Fatalf("filter.page = %#v, want /home", filter["page"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_DesktopSeriesUsesDesktopValueLabel(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{
|
||||
"metric_type": "PAGE_VIEW",
|
||||
"points": []interface{}{
|
||||
map[string]interface{}{
|
||||
"timestamp_ns": float64(1782208800000000000),
|
||||
"value": float64(21),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "page-view",
|
||||
"--series", "desktop", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Values map[string]interface{} `json:"values"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Items) != 1 {
|
||||
t.Fatalf("items len = %d", len(env.Data.Items))
|
||||
}
|
||||
if env.Data.Items[0].Values["desktop"] != float64(21) {
|
||||
t.Fatalf("values = %#v, want desktop=21", env.Data.Items[0].Values)
|
||||
}
|
||||
if _, ok := env.Data.Items[0].Values["page-view"]; ok {
|
||||
t.Fatalf("values should not use page-view label: %#v", env.Data.Items[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_PrettyFormatsTimeFirst(t *testing.T) {
|
||||
const rawNS = int64(1782208800000000000)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{
|
||||
"metric_type": "ACTIVE_USER",
|
||||
"points": []interface{}{
|
||||
map[string]interface{}{"timestamp_ns": float64(rawNS), "value": float64(7)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05")
|
||||
if !strings.HasPrefix(got, "time") {
|
||||
t.Fatalf("pretty output should start with time column, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, wantTime) {
|
||||
t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got)
|
||||
}
|
||||
if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782208800000000000") {
|
||||
t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_PrettySkipsRowsWithoutTime(t *testing.T) {
|
||||
const rawNS = int64(1782208800000000000)
|
||||
rows := []map[string]interface{}{
|
||||
{"timestamp_ns": rawNS, "active-users": float64(7)},
|
||||
{"active-users": float64(0)},
|
||||
}
|
||||
sortObservabilityRowsDesc(rows, "timestamp_ns")
|
||||
rows = filterObservabilityRowsWithTime(rows, "timestamp_ns")
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1: %#v", len(rows), rows)
|
||||
}
|
||||
if rows[0]["timestamp_ns"] != rawNS {
|
||||
t.Fatalf("remaining row = %#v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"series": []interface{}{
|
||||
map[string]interface{}{
|
||||
"metric_type": "TOTAL_USER",
|
||||
"points": []interface{}{
|
||||
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(20)},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"metric_type": "ACTIVE_USER",
|
||||
"points": []interface{}{
|
||||
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(7)},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"metric_type": "NEW_USER",
|
||||
"points": []interface{}{
|
||||
map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(3)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Values map[string]interface{} `json:"values"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Items) != 1 {
|
||||
t.Fatalf("items len = %d", len(env.Data.Items))
|
||||
}
|
||||
values := env.Data.Items[0].Values
|
||||
if values["active-users"] != float64(7) || values["new-users"] != float64(3) || values["total-users"] != float64(20) {
|
||||
t.Fatalf("values = %#v, want active-users=7 new-users=3 total-users=20", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"timestamp_ns": "1782208800000000000",
|
||||
"values": map[string]interface{}{
|
||||
"total-users": float64(4),
|
||||
"active-users": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Values map[string]interface{} `json:"values"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
values := env.Data.Items[0].Values
|
||||
if values["total-users"] != float64(4) || values["active-users"] != float64(0) || values["new-users"] != float64(0) {
|
||||
t.Fatalf("values = %#v, want total-users=4 active-users=0 new-users=0", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_DoesNotFillAllNullValues(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"timestamp_ns": "1782208800000000000",
|
||||
"values": map[string]interface{}{
|
||||
"total-users": nil,
|
||||
"active-users": nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Values map[string]interface{} `json:"values"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
values := env.Data.Items[0].Values
|
||||
if values["total-users"] != nil || values["active-users"] != nil {
|
||||
t.Fatalf("values = %#v, want existing nulls preserved", values)
|
||||
}
|
||||
if _, ok := values["new-users"]; ok {
|
||||
t.Fatalf("values should not fill missing labels when all present values are null: %#v", values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsAnalyticsList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsAnalyticsList, []string{
|
||||
"+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var env struct {
|
||||
Data struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Items == nil {
|
||||
t.Fatalf("items decoded as nil; stdout=%s", stdout.String())
|
||||
}
|
||||
if len(env.Data.Items) != 0 || env.Data.HasMore {
|
||||
t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsTypesMapping(t *testing.T) {
|
||||
types, labels, filter, err := analyticsTypesForCLI("users", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(types, ",") != "ACTIVE_USER,NEW_USER,TOTAL_USER" {
|
||||
t.Fatalf("types = %#v", types)
|
||||
}
|
||||
if strings.Join(labels, ",") != "active-users,new-users,total-users" {
|
||||
t.Fatalf("labels = %#v", labels)
|
||||
}
|
||||
if len(filter) != 0 {
|
||||
t.Fatalf("filter = %#v, want empty", filter)
|
||||
}
|
||||
|
||||
types, labels, filter, err = analyticsTypesForCLI("page-view", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "all" {
|
||||
t.Fatalf("page-view all mapping = %#v %#v", types, labels)
|
||||
}
|
||||
if len(filter) != 0 {
|
||||
t.Fatalf("filter = %#v, want empty", filter)
|
||||
}
|
||||
|
||||
types, labels, filter, err = analyticsTypesForCLI("page-view", "desktop", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "desktop" {
|
||||
t.Fatalf("page-view mapping = %#v %#v", types, labels)
|
||||
}
|
||||
deviceTypes := filter["device_types"].([]string)
|
||||
if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" {
|
||||
t.Fatalf("device_types = %#v", deviceTypes)
|
||||
}
|
||||
|
||||
types, labels, filter, err = analyticsTypesForCLI("page-view", "mobile-view", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "mobile" {
|
||||
t.Fatalf("page-view mobile mapping = %#v %#v", types, labels)
|
||||
}
|
||||
deviceTypes = filter["device_types"].([]string)
|
||||
if len(deviceTypes) != 1 || deviceTypes[0] != "mobile" {
|
||||
t.Fatalf("device_types = %#v", deviceTypes)
|
||||
}
|
||||
|
||||
if _, _, _, err := analyticsTypesForCLI("users", "desktop", ""); err == nil {
|
||||
t.Fatalf("users desktop series should fail")
|
||||
}
|
||||
if _, _, _, err := analyticsTypesForCLI("page-view", "tablet", ""); err == nil {
|
||||
t.Fatalf("page-view tablet series should fail")
|
||||
}
|
||||
if _, _, _, err := analyticsTypesForCLI("page-view", "", "tablet"); err == nil {
|
||||
t.Fatalf("tablet device type should fail")
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。
|
||||
// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON
|
||||
// (INSERT 无 before、DELETE 无 after),json 还原成对象。
|
||||
//
|
||||
// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 /
|
||||
// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。
|
||||
var AppsDBAuditList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-list",
|
||||
Description: "List row-change audit events for one or more tables (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-list --app-id <app_id> --table orders",
|
||||
"Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true},
|
||||
{Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(auditListTables(rctx)) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table")
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditListPath(appID)).
|
||||
Desc("List Miaoda app table audit events").
|
||||
Params(buildAuditListParams(rctx, auditListTables(rctx)))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requested := auditListTables(rctx)
|
||||
env := dbEnv(rctx)
|
||||
|
||||
// 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤,
|
||||
// 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。
|
||||
// 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。
|
||||
queryTables := requested
|
||||
var skipped []auditSkippedEntry
|
||||
if len(requested) > 1 {
|
||||
queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
// 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。
|
||||
if len(queryTables) == 0 {
|
||||
out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, len(requested))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectAuditLogItems(data["items"])
|
||||
data["items"] = items
|
||||
// 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。
|
||||
if len(skipped) > 0 {
|
||||
data["skipped"] = skipped
|
||||
} else {
|
||||
delete(data, "skipped")
|
||||
}
|
||||
multi := len(requested) > 1
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderAuditListPretty(w, items, skipped, len(requested), multi)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。
|
||||
type auditSkippedEntry struct {
|
||||
Table string `json:"table"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。
|
||||
func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) {
|
||||
existing, err := fetchExistingTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
enabled, err := fetchAuditEnabledTables(rctx, appID, env)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
valid := make([]string, 0, len(requested))
|
||||
var skipped []auditSkippedEntry
|
||||
for _, t := range requested {
|
||||
switch {
|
||||
case !existing[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"})
|
||||
case !enabled[t]:
|
||||
skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"})
|
||||
default:
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
return valid, skipped, nil
|
||||
}
|
||||
|
||||
// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。
|
||||
func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
existing := map[string]bool{}
|
||||
token := ""
|
||||
for {
|
||||
params := map[string]interface{}{"env": env, "page_size": 100}
|
||||
if token != "" {
|
||||
params["page_token"] = token
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if name := common.GetString(it, "name"); name != "" {
|
||||
existing[name] = true
|
||||
}
|
||||
}
|
||||
token = common.GetString(data, "page_token")
|
||||
if data["has_more"] != true || token == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。
|
||||
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := map[string]bool{}
|
||||
for _, it := range asMapSlice(data["items"]) {
|
||||
if it["enabled"] == true {
|
||||
if name := common.GetString(it, "table"); name != "" {
|
||||
enabled[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。
|
||||
func asMapSlice(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// auditListTables 取 --table 切片,trim 去空。
|
||||
func auditListTables(rctx *common.RuntimeContext) []string {
|
||||
out := make([]string, 0)
|
||||
for _, t := range rctx.StrSlice("table") {
|
||||
if v := strings.TrimSpace(t); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
|
||||
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"tables": strings.Join(tables, ","),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type auditLogItem struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventTime string `json:"event_time"`
|
||||
TargetTable string `json:"target_table"`
|
||||
Type string `json:"type"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Before interface{} `json:"before,omitempty"`
|
||||
After interface{} `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItem(operator 解析、before/after 还原成对象)。
|
||||
func projectAuditLogItems(raw interface{}) []auditLogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]auditLogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := auditLogItem{
|
||||
EventID: common.GetString(m, "event_id"),
|
||||
EventTime: common.GetString(m, "event_time"),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
Type: common.GetString(m, "type"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
}
|
||||
// before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。
|
||||
if b := common.GetString(m, "before"); b != "" {
|
||||
row.Before = safeParseJSON(b)
|
||||
}
|
||||
if a := common.GetString(m, "after"); a != "" {
|
||||
row.After = safeParseJSON(a)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。
|
||||
func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit events found.\n")
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
return
|
||||
}
|
||||
var headers []string
|
||||
if multi {
|
||||
headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"}
|
||||
} else {
|
||||
headers = []string{"event_time", "type", "event_id", "operator", "summary"}
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)}
|
||||
if multi {
|
||||
cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...)
|
||||
}
|
||||
rows = append(rows, cells)
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
writeAuditSkipped(w, skipped, totalRequested)
|
||||
}
|
||||
|
||||
// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。
|
||||
func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) {
|
||||
if len(skipped) == 0 {
|
||||
return
|
||||
}
|
||||
parts := make([]string, 0, len(skipped))
|
||||
for _, s := range skipped {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason))
|
||||
}
|
||||
fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", "))
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// 审计保留期合法取值。
|
||||
var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"}
|
||||
|
||||
const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id <app_id>`"
|
||||
|
||||
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。
|
||||
var AppsDBAuditEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-enable",
|
||||
Description: "Enable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to enable audit for", Required: true},
|
||||
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectLegacyEnvFlag(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Enable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
retention := rctx.Str("retention")
|
||||
stop := rctx.StartSpinner("Enabling audit logging for " + table)
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
}
|
||||
st := auditSetStatus(data, table)
|
||||
ret := common.GetString(st, "retention")
|
||||
if ret == "" {
|
||||
ret = retention
|
||||
}
|
||||
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBAuditDisable 关闭某张表的行级审计。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。
|
||||
var AppsDBAuditDisable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-disable",
|
||||
Description: "Disable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-disable --app-id <app_id> --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "table to disable audit for", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectLegacyEnvFlag(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appAuditSetPath(appID)).
|
||||
Desc("Disable table audit").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)}).
|
||||
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
|
||||
map[string]interface{}{"env": dbEnv(rctx)},
|
||||
map[string]interface{}{"table": table, "enabled": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbAuditSetHint)
|
||||
}
|
||||
st := auditSetStatus(data, table)
|
||||
out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table"))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。
|
||||
func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} {
|
||||
if st, ok := data["status"].(map[string]interface{}); ok {
|
||||
if common.GetString(st, "table") == "" {
|
||||
st["table"] = table
|
||||
}
|
||||
return st
|
||||
}
|
||||
return map[string]interface{}{"table": table}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false);
|
||||
// 不指定返回所有已配置表。json 单表返对象、多表返数组;pretty 单表 key/value、多表表格。
|
||||
var AppsDBAuditStatus = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-status",
|
||||
Description: "Show table audit (row-change tracking) status",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-status --app-id <app_id>",
|
||||
"Check one table: --table orders",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectLegacyEnvFlag(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appAuditStatusPath(appID)).
|
||||
Desc("Get table audit status").
|
||||
Params(buildAuditStatusParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
items := projectAuditStatusItems(data["items"])
|
||||
// 单表查询但后端无记录 → 占位 enabled=false(与 miaoda 一致)。
|
||||
if table != "" && len(items) == 0 {
|
||||
items = []map[string]interface{}{{"table": table, "enabled": false}}
|
||||
}
|
||||
// json:单表返对象、多表返数组。
|
||||
var out interface{}
|
||||
if table != "" && len(items) == 1 {
|
||||
out = items[0]
|
||||
} else {
|
||||
out = map[string]interface{}{"items": items}
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderAuditStatusPretty(w, items, table)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。
|
||||
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
params["table"] = t
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。
|
||||
func projectAuditStatusItems(raw interface{}) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]interface{}{
|
||||
"table": common.GetString(m, "table"),
|
||||
"enabled": m["enabled"] == true,
|
||||
}
|
||||
if v := common.GetString(m, "enabled_at"); v != "" {
|
||||
row["enabled_at"] = v
|
||||
}
|
||||
if v := common.GetString(m, "retention"); v != "" {
|
||||
row["retention"] = v
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderAuditStatusPretty 单表渲染 key/value、多表渲染对齐表格(table/enabled/enabled_at/retention)。
|
||||
func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No audit configuration found.\n")
|
||||
return
|
||||
}
|
||||
yesNo := func(m map[string]interface{}) string {
|
||||
if m["enabled"] == true {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) }
|
||||
// 单表 → key/value
|
||||
if table != "" && len(items) == 1 {
|
||||
it := items[0]
|
||||
renderKeyValuePairs(w, [][2]string{
|
||||
{"table", common.GetString(it, "table")},
|
||||
{"enabled", yesNo(it)},
|
||||
{"enabled_at", get(it, "enabled_at")},
|
||||
{"retention", get(it, "retention")},
|
||||
})
|
||||
return
|
||||
}
|
||||
// 多表 → 表格
|
||||
headers := []string{"table", "enabled", "enabled_at", "retention"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status"
|
||||
dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set"
|
||||
dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list"
|
||||
dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables"
|
||||
)
|
||||
|
||||
// ── audit-status ──
|
||||
|
||||
// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。
|
||||
func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// 单表无记录 → 占位对象 enabled:false(不是数组)。
|
||||
var env struct {
|
||||
Data struct {
|
||||
Table string `json:"table"`
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Table != "orders" || env.Data.Enabled {
|
||||
t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。
|
||||
func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"},
|
||||
map[string]interface{}{"table": "users", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditStatus,
|
||||
[]string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") {
|
||||
t.Fatalf("pretty table malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-enable / disable ──
|
||||
|
||||
// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。
|
||||
func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required, exit 1
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
// 非法 retention → enum 校验 (validation)
|
||||
factory2, stdout2, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--retention" {
|
||||
t.Fatalf("Param = %q, want --retention", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) {
|
||||
// dry-run body {table, enabled:true, retention}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
|
||||
// success
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditEnable,
|
||||
[]string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。
|
||||
func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" {
|
||||
t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body)
|
||||
}
|
||||
|
||||
factory2, stdout2, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbAuditSetURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditDisable,
|
||||
[]string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") {
|
||||
t.Fatalf("pretty: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── audit-list ──
|
||||
|
||||
// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBAuditList_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --table error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。
|
||||
func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" {
|
||||
t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"])
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。
|
||||
// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。
|
||||
func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users",
|
||||
"type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field",
|
||||
"before": `{"amount":100}`, "after": `{"amount":999}`,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// operator → 对象;before/after → 还原成对象(非字符串)。
|
||||
for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"skipped"`) {
|
||||
t.Errorf("single-table query must not emit skipped:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `"before": "{`) {
|
||||
t.Errorf("before should be an object, not a JSON string:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。
|
||||
func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("empty audit list should NOT error (ok read), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") {
|
||||
t.Fatalf("expected empty, no skipped for single table:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list,
|
||||
// 不存在 / 未开启审计的表进 skipped。
|
||||
// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。
|
||||
func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// schema:orders/users/carts 存在,ghost 不存在。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"},
|
||||
}}},
|
||||
})
|
||||
// status:orders/users 开启审计,carts 未开启。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true},
|
||||
map[string]interface{}{"table": "carts", "enabled": false},
|
||||
}}},
|
||||
})
|
||||
// audit_list 只应被传入有效表 orders,users。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditListURL,
|
||||
OnMatch: func(req *http.Request) {
|
||||
if got := req.URL.Query().Get("tables"); got != "orders,users" {
|
||||
t.Errorf("audit_list tables = %q, want orders,users (filtered)", got)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"},
|
||||
}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
// skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。
|
||||
for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。
|
||||
// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。
|
||||
func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbTablesListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{
|
||||
map[string]interface{}{"name": "orders"},
|
||||
}}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbAuditStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
// 不注册 audit_list:若被调用会命中未注册请求而报错。
|
||||
if err := runAppsShortcut(t, AppsDBAuditList,
|
||||
[]string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("all-filtered should still succeed (empty), got %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") {
|
||||
t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbChangelogHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
|
||||
|
||||
// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。
|
||||
// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name},
|
||||
// json 还原成对象、pretty 只展示 name。
|
||||
var AppsDBChangelogList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-changelog-list",
|
||||
Description: "List a Miaoda app database's DDL change history (cursor pagination)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-changelog-list --app-id <app_id>",
|
||||
"Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "filter by target table"},
|
||||
{Name: "change-id", Desc: "look up a single change by id (returns that one record only)"},
|
||||
{Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"},
|
||||
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "since", "until")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appChangelogListPath(appID)).
|
||||
Desc("List Miaoda app DDL changelog").
|
||||
Params(buildChangelogParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbChangelogHint)
|
||||
}
|
||||
items := projectChangelogItems(data["items"])
|
||||
data["items"] = items
|
||||
changeID := strings.TrimSpace(rctx.Str("change-id"))
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderChangelogPretty(w, items, changeID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。
|
||||
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("table", "table")
|
||||
addStr("change-id", "change_id")
|
||||
addStr("since", "since")
|
||||
addStr("until", "until")
|
||||
addStr("page-token", "page_token")
|
||||
return params
|
||||
}
|
||||
|
||||
type changelogItem struct {
|
||||
ChangeID string `json:"change_id"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Operator *operatorRef `json:"operator,omitempty"`
|
||||
TargetTable string `json:"target_table"`
|
||||
ChangeType string `json:"change_type"`
|
||||
Summary string `json:"summary"`
|
||||
Statement string `json:"statement,omitempty"`
|
||||
}
|
||||
|
||||
// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItem(operator 解析成对象)。
|
||||
func projectChangelogItems(raw interface{}) []changelogItem {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]changelogItem, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, changelogItem{
|
||||
ChangeID: common.GetString(m, "change_id"),
|
||||
ChangedAt: common.GetString(m, "changed_at"),
|
||||
Operator: parseOperator(common.GetString(m, "operator")),
|
||||
TargetTable: common.GetString(m, "target_table"),
|
||||
ChangeType: common.GetString(m, "change_type"),
|
||||
Summary: common.GetString(m, "summary"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。
|
||||
func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) {
|
||||
if len(items) == 0 {
|
||||
if changeID != "" {
|
||||
fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID)
|
||||
} else {
|
||||
io.WriteString(w, "No DDL changes found.\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{
|
||||
it.ChangeID,
|
||||
dashIfEmpty(it.ChangedAt),
|
||||
operatorName(it.Operator),
|
||||
dashIfEmpty(it.TargetTable),
|
||||
it.ChangeType,
|
||||
dashIfEmpty(it.Summary),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list"
|
||||
|
||||
// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。
|
||||
func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--environment", "dev", "--table", "orders",
|
||||
"--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbChangelogURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" {
|
||||
t.Fatalf("params = %v", a.Params)
|
||||
}
|
||||
if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_RejectsBadSince 验证不可解析的 --since 报 --since 的 ValidationError。
|
||||
func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--since" {
|
||||
t.Fatalf("Param = %q, want --since", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_SuccessParsesOperator 验证成功响应中 operator JSON 串被解析为对象并输出变更字段。
|
||||
func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbChangelogURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"has_more": false, "page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"change_id": "01J", "changed_at": "2026-04-15T10:30:00Z",
|
||||
"operator": `{"id":"7311","name":"alice"}`, "target_table": "orders",
|
||||
"change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBChangelogList_ChangeIDNotFoundPretty 验证按 --change-id 查询无结果时 pretty 打印 not-found 提示。
|
||||
func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbChangelogURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBChangelogList,
|
||||
[]string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") {
|
||||
t.Fatalf("expected not-found message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOperator_Cases 验证 parseOperator 处理合法 JSON、空 name 回退 id、非 JSON 原样、空串返回 nil,以及 operatorName(nil) 为占位符。
|
||||
func TestParseOperator_Cases(t *testing.T) {
|
||||
if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" {
|
||||
t.Fatalf("valid: %#v", op)
|
||||
}
|
||||
if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" {
|
||||
t.Fatalf("name fallback to id: %#v", op)
|
||||
}
|
||||
if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" {
|
||||
t.Fatalf("non-json raw: %#v", op)
|
||||
}
|
||||
if op := parseOperator(""); op != nil {
|
||||
t.Fatalf("empty → nil, got %#v", op)
|
||||
}
|
||||
if operatorName(nil) != "—" {
|
||||
t.Fatalf("nil operatorName should be —")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeParseJSON_Cases 验证 safeParseJSON 合法 JSON 解析为对象、非法 JSON 原样返回字符串。
|
||||
func TestSafeParseJSON_Cases(t *testing.T) {
|
||||
if v := safeParseJSON(`{"a":1}`); v == nil {
|
||||
t.Fatalf("valid json → object")
|
||||
}
|
||||
if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" {
|
||||
t.Fatalf("invalid json → raw string, got %v", v)
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataExportMaxRows = 5000
|
||||
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
|
||||
|
||||
// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。
|
||||
// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。
|
||||
// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 <table>.csv);上限 5000 行 / 1 MB。
|
||||
var AppsDBDataExport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-export",
|
||||
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
|
||||
"Format follows the --output extension: .csv / .json / .sql (default csv).",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "table", Desc: "source table", Required: true},
|
||||
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
|
||||
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table")
|
||||
}
|
||||
if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit")
|
||||
}
|
||||
if _, _, err := exportFormatAndOutput(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
format, _, _ := exportFormatAndOutput(rctx)
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDataExportPath(appID)).
|
||||
Desc("Export Miaoda app table data (raw bytes)").
|
||||
Params(map[string]interface{}{
|
||||
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
|
||||
"format": format, "limit": rctx.Int("limit"),
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
format, out, err := exportFormatAndOutput(rctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。
|
||||
// total 查询失败不阻断导出——回退到按导出文件内容数行。
|
||||
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: appDataExportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{
|
||||
"env": []string{dbEnv(rctx)},
|
||||
"table": []string{table},
|
||||
"format": []string{format},
|
||||
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
// 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。
|
||||
if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' {
|
||||
if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil {
|
||||
return withAppsHint(cerr, dbDataExportHint)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint)
|
||||
}
|
||||
body := resp.RawBody
|
||||
if len(body) > dbDataExportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body))
|
||||
}
|
||||
|
||||
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: int64(len(body)),
|
||||
}, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output")
|
||||
}
|
||||
// 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。
|
||||
rows := 0
|
||||
if totalErr == nil {
|
||||
rows = total
|
||||
if lim := rctx.Int("limit"); rows > lim {
|
||||
rows = lim
|
||||
}
|
||||
} else {
|
||||
rows = countDataRows(body, format)
|
||||
}
|
||||
resolved, perr := rctx.FileIO().ResolvePath(out)
|
||||
if perr != nil || resolved == "" {
|
||||
resolved = out
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"table": table, "output": resolved, "format": format,
|
||||
"rows": rows, "size_bytes": saved.Size(),
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。
|
||||
// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。
|
||||
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
|
||||
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
|
||||
map[string]interface{}{"env": env, "page_size": 1}, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return totalAsInt(raw["total"]), nil
|
||||
}
|
||||
|
||||
// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。
|
||||
func totalAsInt(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exportFormatAndOutput 由 --output 推断数据格式与落盘路径:
|
||||
// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出 <table>.csv。
|
||||
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
return "csv", table + ".csv", nil
|
||||
}
|
||||
f, ferr := resolveDataFormat(filepath.Ext(out), true)
|
||||
if ferr != nil {
|
||||
return "", "", ferr
|
||||
}
|
||||
return f, out, nil
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export"
|
||||
const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records"
|
||||
|
||||
// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。
|
||||
func TestAppsDBDataExport_RequiresTable(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// 缺 --table → cobra required-flag, exit 1
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected required-flag error for missing --table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。
|
||||
func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) {
|
||||
for _, lim := range []string{"0", "-1", "5001"} {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err)
|
||||
}
|
||||
if ve.Param != "--limit" {
|
||||
t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。
|
||||
func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation for .xml, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:format 跟随 --output 扩展名;缺省 csv。
|
||||
// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。
|
||||
func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) {
|
||||
cases := []struct{ output, wantFmt string }{
|
||||
{"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}
|
||||
if c.output != "" {
|
||||
args = append(args, "--output", c.output)
|
||||
}
|
||||
if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != dbDataExportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" {
|
||||
t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt)
|
||||
}
|
||||
if _, ok := a.Params["limit"]; !ok {
|
||||
t.Errorf("dry-run missing limit param")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 成功:先查 records 列表 total 计行,再把原始字节落盘。
|
||||
// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。
|
||||
func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
// 第 1 步:records 列表 total=2(行数来源)。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}},
|
||||
})
|
||||
// 第 2 步:导出原始字节。
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n"),
|
||||
Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
b, err := os.ReadFile(dir + "/orders.csv")
|
||||
if err != nil || string(b) != "id,name\n1,a\n2,b\n" {
|
||||
t.Fatalf("output file wrong: %q err=%v", string(b), err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) {
|
||||
t.Fatalf("output json missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 行数取自 records total,且按 --limit 截顶(min(total, limit))。
|
||||
// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。
|
||||
func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 100`) {
|
||||
t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。
|
||||
// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。
|
||||
func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) {
|
||||
dir := chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbOrdersRecordsURL,
|
||||
Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbDataExportURL,
|
||||
RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("export should still succeed via fallback, got %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(dir + "/orders.csv")
|
||||
if string(b) != "id,name\n1,a\n2,b\n3,c\n" {
|
||||
t.Fatalf("file not written on fallback path: %q", string(b))
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"rows": 3`) {
|
||||
t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。
|
||||
// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。
|
||||
func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: dbDataExportURL,
|
||||
RawBody: []byte(`{"code":1254043,"msg":"table not found"}`),
|
||||
Headers: http.Header{"Content-Type": []string{"application/json"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBDataExport,
|
||||
[]string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
if _, statErr := os.Stat("nope.csv"); statErr == nil {
|
||||
t.Fatalf("error path must not write the output file")
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches"
|
||||
|
||||
// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与
|
||||
// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成
|
||||
// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。
|
||||
var AppsDBDataImport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-import",
|
||||
Description: "Import rows from a local csv/json file into a Miaoda app table",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-import --app-id <app_id> --file ./orders.csv --yes",
|
||||
"Table defaults to the file name; override with --table.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
|
||||
{Name: "table", Desc: "target table (default: file name without extension)"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("file")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
// 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。
|
||||
if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil {
|
||||
return err
|
||||
}
|
||||
// 体积守卫前移到 Validate:用 Stat 先查大小(不读内容),dry-run 也能拦超大文件、且
|
||||
// 在读整个文件进内存之前就失败(对齐 +file-upload)。Stat 失败不在此报错,留给 Execute
|
||||
// 的 ReadInputFile 产出更精确的「文件不存在/越界」错误。
|
||||
if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file")
|
||||
}
|
||||
if importTableName(rctx) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
fileName := filepath.Base(strings.TrimSpace(rctx.Str("file")))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appDataImportPath(appID)).
|
||||
Desc("Import data file into Miaoda app table (multipart upload)").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
|
||||
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file")
|
||||
}
|
||||
if len(content) > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file")
|
||||
}
|
||||
fileName := filepath.Base(file)
|
||||
table := importTableName(rctx)
|
||||
|
||||
// multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
|
||||
Body: fd,
|
||||
}, larkcore.WithFileUpload())
|
||||
if err != nil {
|
||||
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint)
|
||||
}
|
||||
data, err := rctx.ClassifyAPIResponse(resp)
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbDataImportHint)
|
||||
}
|
||||
|
||||
outTable := common.GetString(data, "table")
|
||||
if outTable == "" {
|
||||
outTable = table
|
||||
}
|
||||
rows := int64(0)
|
||||
if f, ok := numericAsFloat(data["rows"]); ok {
|
||||
rows = int64(f)
|
||||
}
|
||||
out := map[string]interface{}{"file": file, "table": outTable, "rows": rows}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// importTableName 取目标表名:--table 优先,否则文件名去扩展名。
|
||||
func importTableName(rctx *common.RuntimeContext) string {
|
||||
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
|
||||
return t
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return ""
|
||||
}
|
||||
base := filepath.Base(f)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import"
|
||||
|
||||
// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。
|
||||
func chdirTemp(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。
|
||||
func TestAppsDBDataImport_RequiresAppID(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt)报不支持格式的校验错误。
|
||||
func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("data.txt", []byte("x\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected unsupported-format validation, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。
|
||||
func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
||||
t.Fatalf("expected confirmation_required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。
|
||||
func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
// >1MB → size 校验
|
||||
big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...)
|
||||
_ = os.WriteFile("big.csv", big, 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected 1MB limit error, got %T %v", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。
|
||||
// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态:file_name+file 走 body、env+table 走 query 且不再发 format。
|
||||
func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbDataImportURL {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil {
|
||||
t.Fatalf("dry-run body should carry file_name + file: %v", a.Body)
|
||||
}
|
||||
if _, ok := a.Body["format"]; ok {
|
||||
t.Fatalf("format must no longer be sent: %v", a.Body)
|
||||
}
|
||||
if a.Params["env"] != "dev" || a.Params["table"] != "orders" {
|
||||
t.Fatalf("dry-run params (env+table) = %v", a.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
|
||||
func TestAppsDBDataImport_Success(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600)
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbDataImportURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) {
|
||||
t.Fatalf("output missing fields:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名(customers.json→customers)。
|
||||
func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
_ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBDataImport,
|
||||
[]string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Params["table"] != "customers" {
|
||||
t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --environment dev`"
|
||||
const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBEnvCreate creates a DB environment for an app(拆分单库为 dev/online 多环境)。
|
||||
//
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。
|
||||
// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。
|
||||
var AppsDBEnvCreate = common.Shortcut{
|
||||
Service: appsService,
|
||||
@@ -24,20 +24,19 @@ var AppsDBEnvCreate = common.Shortcut{
|
||||
Description: "Create a DB environment (split single-env DB into dev/online, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-create --environment dev --sync-data --app-id <app_id> --yes",
|
||||
"Example: lark-cli apps +db-env-create --env dev --sync-data --app-id <app_id> --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"},
|
||||
{Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"},
|
||||
}, dbEnvFlags("dev", []string{"dev"}, "environment to create (only dev supported for now)")...),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectLegacyEnvFlag(rctx)
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -63,7 +62,7 @@ var AppsDBEnvCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
// buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。
|
||||
// --environment 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
|
||||
// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。
|
||||
func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"sync_data": rctx.Bool("sync-data"),
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) {
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) {
|
||||
}
|
||||
reg.Register(stub)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) {
|
||||
func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) {
|
||||
func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBEnvCreate,
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--environment", "online", "--yes", "--as", "user"},
|
||||
[]string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`"
|
||||
|
||||
// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。
|
||||
// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。
|
||||
var AppsDBEnvDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-diff",
|
||||
Description: "Preview pending dev→online schema changes (no apply)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-diff --app-id <app_id>",
|
||||
"Apply the previewed changes with +db-env-migrate --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(data, "from"), common.GetString(data, "to")
|
||||
changes := projectMigrationChanges(data["changes"])
|
||||
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderMigrationDiff(w, from, to, changes)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status
|
||||
// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
|
||||
var AppsDBEnvMigrate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-migrate",
|
||||
Description: "Publish pending dev→online schema changes (irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
|
||||
"Preview first with +db-env-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
applied = len(projectMigrationChanges(submit["changes"]))
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "applied", "migrated":
|
||||
return true, nil
|
||||
case "failed":
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if n := intFromAny(final["changes_applied"]); n > 0 {
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type migrationChange struct {
|
||||
Type string `json:"type"`
|
||||
Table string `json:"table"`
|
||||
Statement string `json:"statement"`
|
||||
}
|
||||
|
||||
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。
|
||||
func projectMigrationChanges(raw interface{}) []migrationChange {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]migrationChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, migrationChange{
|
||||
Type: common.GetString(m, "type"),
|
||||
Table: common.GetString(m, "table"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
|
||||
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
|
||||
if len(changes) == 0 {
|
||||
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s\n", c.Statement)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。
|
||||
func migrateFailMsg(d map[string]interface{}, taskID string) string {
|
||||
if m := common.GetString(d, "error_message"); m != "" {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
|
||||
}
|
||||
|
||||
// intFromAny 把 JSON number / json.Number 转 int(计数用)。
|
||||
func intFromAny(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
const (
|
||||
dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate"
|
||||
dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status"
|
||||
dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery"
|
||||
dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status"
|
||||
dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status"
|
||||
dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota"
|
||||
)
|
||||
|
||||
// ── env-diff ──
|
||||
|
||||
// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体:POST env_migrate 且 dry_run=true。
|
||||
func TestAppsDBEnvDiff_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。
|
||||
func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"from": "dev", "to": "online",
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") {
|
||||
t.Fatalf("pretty diff malformed:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。
|
||||
func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvDiff,
|
||||
[]string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No pending changes from dev to online.") {
|
||||
t.Fatalf("expected empty message, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── env-migrate ──
|
||||
|
||||
// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false(真实迁移)。
|
||||
func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
if env.API[0].Body["dry_run"] != false {
|
||||
t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。
|
||||
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") {
|
||||
t.Fatalf("pretty: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
|
||||
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbEnvMigrateURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbEnvMigrateStatusURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "lock timeout") {
|
||||
t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "+db-env-diff") {
|
||||
t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。
|
||||
func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。
|
||||
if err := runAppsShortcut(t, AppsDBEnvMigrate,
|
||||
[]string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-diff ──
|
||||
|
||||
// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。
|
||||
func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected required --target error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。
|
||||
func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true {
|
||||
t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body)
|
||||
}
|
||||
if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。
|
||||
func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"preview_status": "success", "tables_affected": 2, "estimated_seconds": 12,
|
||||
"changes": []interface{}{
|
||||
map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2},
|
||||
map[string]interface{}{"table": "carts", "action": "restore_table"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error,携带 message 与 PITR window hint。
|
||||
func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryDiffURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBRecoveryDiff,
|
||||
[]string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("got %T %v, want API/server_error typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "snapshot expired") {
|
||||
t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "PITR window") {
|
||||
t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery-apply ──
|
||||
|
||||
// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。
|
||||
func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "No changes — database is already at this state.") {
|
||||
t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。
|
||||
func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: dbRecoveryURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbRecoveryApplyURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") {
|
||||
t.Fatalf("pretty: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。
|
||||
func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBRecoveryApply,
|
||||
[]string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil {
|
||||
t.Fatalf("expected confirmation gate without --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// ── quota-get ──
|
||||
|
||||
// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。
|
||||
func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0,
|
||||
"tables": 4, "views": 1,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。
|
||||
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: dbQuotaURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0,
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBQuotaGet,
|
||||
[]string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") {
|
||||
t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") {
|
||||
t.Fatalf("expected used + tables retained:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views(及配额已对接时的
|
||||
// quota/usage_percent),后端额外字段不透传。
|
||||
func TestProjectDbQuota_WhitelistsFields(t *testing.T) {
|
||||
out := projectDbQuota(map[string]interface{}{
|
||||
"storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0),
|
||||
"tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1",
|
||||
})
|
||||
if _, ok := out["storage_quota_bytes"]; ok {
|
||||
t.Errorf("zero quota should be omitted: %v", out)
|
||||
}
|
||||
if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 {
|
||||
t.Errorf("whitelisted fields should be kept: %v", out)
|
||||
}
|
||||
for _, leaked := range []string{"tenant_key", "internal_shard"} {
|
||||
if _, ok := out[leaked]; ok {
|
||||
t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out)
|
||||
}
|
||||
}
|
||||
|
||||
out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2})
|
||||
if _, ok := out2["storage_quota_bytes"]; !ok {
|
||||
t.Errorf("non-zero quota should be kept: %v", out2)
|
||||
}
|
||||
if _, ok := out2["usage_percent"]; !ok {
|
||||
t.Errorf("usage_percent should be kept when quota>0: %v", out2)
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBExecute executes SQL against a Miaoda app database.
|
||||
// AppsDBExecute executes SQL against an app database.
|
||||
//
|
||||
// POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式
|
||||
// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。
|
||||
@@ -31,18 +31,12 @@ import (
|
||||
// - 多语句部分失败:`Statement K: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵
|
||||
// 后升级成 typed errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息
|
||||
// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无
|
||||
// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条
|
||||
// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。
|
||||
// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results /
|
||||
// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判
|
||||
// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit
|
||||
// 落地,故 rolled_back=false(真机 boe 实证)。
|
||||
//
|
||||
// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`)
|
||||
// - 单 DML → data = `{command, rows_affected}`
|
||||
// - 单 DDL → data = `{command}`
|
||||
// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]`
|
||||
//
|
||||
// 字段裁剪用框架原生 --jq/-q。
|
||||
// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。
|
||||
//
|
||||
// Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。
|
||||
//
|
||||
@@ -51,45 +45,51 @@ import (
|
||||
var AppsDBExecute = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-execute",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database",
|
||||
Description: "Execute SQL (SELECT / DML / DDL) against an app database",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --environment dev --file ./migration.sql --yes`,
|
||||
"Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'",
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
|
||||
Input: []string{common.Stdin}},
|
||||
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
{Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
sql := strings.TrimSpace(rctx.Str("sql"))
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
if sql != "" && file != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sql and --file are mutually exclusive")
|
||||
return appsValidationError("--sql and --file are mutually exclusive").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "mutually exclusive with --file"),
|
||||
appsInvalidParam("--file", "mutually exclusive with --sql"),
|
||||
)
|
||||
}
|
||||
if file != "" {
|
||||
data, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err)
|
||||
return appsValidationParamError("--file", "--file: %v", err).WithCause(err)
|
||||
}
|
||||
// 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。
|
||||
rctx.Cmd.Flags().Set("sql", string(data))
|
||||
sql = strings.TrimSpace(string(data))
|
||||
}
|
||||
if sql == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --sql or --file is required (use --sql - to read stdin)")
|
||||
return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)").
|
||||
WithParams(
|
||||
appsInvalidParam("--sql", "one of --sql or --file is required"),
|
||||
appsInvalidParam("--file", "one of --sql or --file is required"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -97,7 +97,7 @@ var AppsDBExecute = common.Shortcut{
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appSQLPath(appID)).
|
||||
Desc("Execute SQL on Miaoda app database").
|
||||
Desc("Execute SQL on app database").
|
||||
Params(buildDBSQLParams(rctx)).
|
||||
Body(buildDBSQLBody(rctx))
|
||||
},
|
||||
@@ -110,30 +110,27 @@ var AppsDBExecute = common.Shortcut{
|
||||
buildDBSQLParams(rctx),
|
||||
buildDBSQLBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--environment dev`")
|
||||
return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table <table>`; for day-to-day debugging target the dev database with `--env dev`")
|
||||
}
|
||||
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态,
|
||||
// server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results,
|
||||
// 让 json/pretty 路径都基于同一份反序列化产物渲染。
|
||||
stmts := parseSQLResult(common.GetString(raw, "result"))
|
||||
// JSON data 形态(不再透传后端 result 字符串):
|
||||
// - 单 SELECT → data 是行数组 [{...}](空 → [])
|
||||
// - 单 DML → data = {command, rows_affected}
|
||||
// - 单 DDL → data = {command}
|
||||
// - 多语句 → data = [{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]
|
||||
// 字段裁剪走框架原生 --jq/-q(不引入 miaoda 的 --json <fields>)。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错
|
||||
// (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := shapeSQLData(stmts)
|
||||
// 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。
|
||||
// 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接
|
||||
// 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。
|
||||
data := map[string]interface{}{"results": stmts}
|
||||
|
||||
// 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。
|
||||
// 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。
|
||||
// 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据
|
||||
// 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。
|
||||
// pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。
|
||||
if errIdx, errStmt, failed := findErrorSentinel(stmts); failed {
|
||||
if rctx.Format == "pretty" {
|
||||
renderSQLPretty(rctx.IO().Out, stmts)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return sqlStatementError(stmts, errIdx, errStmt)
|
||||
return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil)
|
||||
}
|
||||
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
@@ -143,70 +140,6 @@ var AppsDBExecute = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态:
|
||||
// - 无语句 → [](空数组)
|
||||
// - 单条语句 → singleStatementJSON(SELECT 是行数组、DML/DDL 是对象)
|
||||
// - 多条语句 → []multiStatementElement(每条统一成 {command,...} 对象,SELECT 行放 rows)
|
||||
//
|
||||
// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。
|
||||
func shapeSQLData(stmts []map[string]interface{}) interface{} {
|
||||
if len(stmts) == 0 {
|
||||
return []interface{}{}
|
||||
}
|
||||
if len(stmts) == 1 {
|
||||
return singleStatementJSON(stmts[0])
|
||||
}
|
||||
out := make([]interface{}, 0, len(stmts))
|
||||
for _, s := range stmts {
|
||||
out = append(out, multiStatementElement(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// singleStatementJSON 单条语句的 PRD JSON 形态:
|
||||
// - SELECT → 行数组(空 → [])
|
||||
// - DML → {command, rows_affected}
|
||||
// - DDL / OK / 其它 → {command}
|
||||
func singleStatementJSON(s map[string]interface{}) interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return selectRows(s)
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成
|
||||
// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。
|
||||
func multiStatementElement(s map[string]interface{}) map[string]interface{} {
|
||||
sqlType := common.GetString(s, "sql_type")
|
||||
switch {
|
||||
case sqlType == "SELECT":
|
||||
return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)}
|
||||
case isDMLType(sqlType):
|
||||
return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])}
|
||||
default:
|
||||
return map[string]interface{}{"command": sqlType}
|
||||
}
|
||||
}
|
||||
|
||||
// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组;
|
||||
// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null)。
|
||||
func selectRows(s map[string]interface{}) []map[string]interface{} {
|
||||
dataJSON := strings.TrimSpace(common.GetString(s, "data"))
|
||||
if dataJSON == "" || dataJSON == "null" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。
|
||||
// 返回失败语句下标(0-based)、该 ERROR statement、是否命中。
|
||||
func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) {
|
||||
@@ -218,48 +151,28 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIError(CategoryAPI → exit 1)。
|
||||
// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。
|
||||
//
|
||||
// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进
|
||||
// message + hint 的人类可读文案(errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐
|
||||
// miaoda-cli(src/cli/handlers/db/sql.ts、src/api/db/api.ts):
|
||||
// - message 末尾 "(at statement N of M)" 给出失败位置;
|
||||
// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回):
|
||||
// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted.";
|
||||
// 否则前序语句已逐条 commit、未回滚(flat 信封无逐句 breakdown,故 hint 简述前序已落地 + 从失败处续跑)。
|
||||
func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error {
|
||||
// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit
|
||||
// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在
|
||||
// 失败位置),note 提示用户别整批重跑(否则会重复写入)。
|
||||
func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} {
|
||||
code, msg := parseErrorSentinel(common.GetString(errStmt, "data"))
|
||||
stmtNo := errIdx + 1 // 1-based 给人看
|
||||
fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts))
|
||||
|
||||
var hint string
|
||||
switch {
|
||||
case inferRolledBack(stmts[:errIdx]):
|
||||
hint = "Transaction rolled back; no changes persisted."
|
||||
case errIdx > 0:
|
||||
hint = fmt.Sprintf("Earlier statements were committed and not rolled back; fix statement %d and re-run the remaining statements.", stmtNo)
|
||||
default:
|
||||
hint = "No statements were applied; fix the SQL and re-run."
|
||||
note := "no statements were applied; fix the SQL and re-run."
|
||||
if errIdx > 0 {
|
||||
note = fmt.Sprintf(
|
||||
"statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.",
|
||||
errIdx, stmtNo)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。
|
||||
// 遍历已完成语句的 sql_type:BEGIN/START TRANSACTION +1,COMMIT/ROLLBACK/END -1;
|
||||
// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。
|
||||
func inferRolledBack(completed []map[string]interface{}) bool {
|
||||
depth := 0
|
||||
for _, s := range completed {
|
||||
switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) {
|
||||
case "BEGIN", "START TRANSACTION", "START_TRANSACTION":
|
||||
depth++
|
||||
case "COMMIT", "ROLLBACK", "END":
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"results": stmts,
|
||||
"statement_index": errIdx,
|
||||
"error_code": code,
|
||||
"error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)),
|
||||
"rolled_back": false,
|
||||
"note": note,
|
||||
}
|
||||
return depth > 0
|
||||
}
|
||||
|
||||
// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。
|
||||
@@ -292,7 +205,7 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"env": rctx.Str("env"),
|
||||
"transactional": false,
|
||||
}
|
||||
}
|
||||
@@ -441,10 +354,10 @@ func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) {
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
if failedIdx >= 0 {
|
||||
// CLI 永远传 transactional=false,失败语句之前的语句已逐条 commit 落地、不会整批回滚——
|
||||
// 如实告诉用户,避免整批重跑导致重复写入。
|
||||
// CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地,
|
||||
// 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。
|
||||
if successCount > 0 {
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n",
|
||||
fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n",
|
||||
failedIdx+1, successCount, plural(int64(successCount)))
|
||||
} else {
|
||||
fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1)
|
||||
@@ -548,7 +461,6 @@ func isDMLType(sqlType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// dmlVerb 把 DML sql_type 映射成过去分词动词:INSERT→inserted / UPDATE→updated / DELETE→deleted / MERGE→merged,未知 → affected。
|
||||
func dmlVerb(sqlType string) string {
|
||||
switch strings.ToUpper(sqlType) {
|
||||
case "INSERT":
|
||||
@@ -563,7 +475,6 @@ func dmlVerb(sqlType string) string {
|
||||
return "affected"
|
||||
}
|
||||
|
||||
// plural 返回英文复数后缀:n==1 时空串,否则 "s"。
|
||||
func plural(n int64) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
|
||||
@@ -5,18 +5,17 @@ package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。
|
||||
func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
|
||||
func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -34,134 +33,27 @@ func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) {
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 SELECT:data 直接是行数组(不再是 data.results[].data 字符串)
|
||||
// JSON envelope 应该把 result 字符串 parse 之后放进 data.results
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), stdout.String())
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
|
||||
}
|
||||
if env.Data[0]["id"] != float64(101) || env.Data[0]["total_cents"] != float64(2500) {
|
||||
t.Fatalf("data[0] = %v, want {id:101,total_cents:2500}", env.Data[0])
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。
|
||||
func TestAppsDBExecute_SingleDMLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"INSERT","data":"","affected_rows":3}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DML:data = {command, rows_affected}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
RowsAffected int `json:"rows_affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 {
|
||||
t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。
|
||||
func TestAppsDBExecute_SingleDDLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DDL:data = {command}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "CREATE_TABLE" {
|
||||
t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。
|
||||
func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) {
|
||||
t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0])
|
||||
}
|
||||
if env.Data[1]["command"] != "SELECT" {
|
||||
t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"])
|
||||
}
|
||||
rows, ok := env.Data[1]["rows"].([]interface{})
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。
|
||||
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--environment", "dev", "--dry-run", "--as", "user"},
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -193,7 +85,6 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_RejectsEmptySQL 断言 --sql 全空白时校验报错(提示需要 --sql 或 --file)。
|
||||
func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
@@ -203,23 +94,6 @@ func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyEnvFlagRejected 钉死:旧名 --env 已移除,显式传入报 validation 错并指向 --environment。
|
||||
func TestAppsDBExecute_LegacyEnvFlagRejected(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("--env should be rejected; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation {
|
||||
t.Fatalf("want a typed validation error, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "--environment") {
|
||||
t.Errorf("message should point to --environment: %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// --sql 与 --file 互斥
|
||||
func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
@@ -250,7 +124,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--app-id", "app_x", "--environment", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
|
||||
[]string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -273,7 +147,6 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) {
|
||||
// 输入用 BOE 真实抓包数据(test_scripts/boe_e2e/run.log)。
|
||||
// ============================================================================
|
||||
|
||||
// TestAppsDBExecute_LegacyWireSingleSelect 断言 legacy 字符串数组 wire 的单 SELECT 能正常渲染表格、不回退到 RAW。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -305,9 +178,8 @@ func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
|
||||
// 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态(data 直接是行)
|
||||
func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) {
|
||||
// 验证 JSON envelope 也把 legacy result 正确归一化进 data.results
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -325,20 +197,24 @@ func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Data struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data)
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results)
|
||||
}
|
||||
if env.Data[0]["x"] != float64(1) {
|
||||
t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"])
|
||||
if env.Data.Results[0]["sql_type"] != "SELECT" {
|
||||
t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"])
|
||||
}
|
||||
if env.Data.Results[0]["record_count"] != float64(1) {
|
||||
t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelect 断言 legacy wire 多 SELECT 输出带 Statement N header 与末尾 "✓ N statements executed" 汇总。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
// BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]"
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -368,7 +244,6 @@ func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时(legacy DDL)pretty 输出 "(empty result)"。
|
||||
func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
// BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows)
|
||||
// 老 wire 不区分 DDL/DML/无返回,统一标 "ok"
|
||||
@@ -395,7 +270,6 @@ func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_LegacyWireMultiSelectWithRealTable 断言含 CJK / uuid / int 字段的真实表行能正确显示在 pretty 表格里。
|
||||
func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) {
|
||||
// BOE 实测真实表抓包(course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
@@ -454,7 +328,6 @@ func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyEmptySelect 断言空 SELECT 的 pretty 输出为 "(0 rows)"。
|
||||
func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -477,7 +350,6 @@ func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) <verb>"、各类 DDL(含细粒度动词)渲染 "✓ DDL executed"。
|
||||
func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -514,7 +386,6 @@ func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsAllSuccess 断言多语句全成功时逐条 Statement 摘要 + 末尾 "✓ N statements executed"。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -584,7 +455,6 @@ func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel 断言多语句部分失败时 pretty 仍打逐条 ✓/✗ 摘要、声明前序已 commit 未回滚,且返回 typed error、不打成功汇总。
|
||||
func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -616,20 +486,19 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t
|
||||
t.Errorf("missing %q in pretty output\nfull:\n%s", line, got)
|
||||
}
|
||||
}
|
||||
// 非事务(transactional=false)前序语句已逐条 commit 落地,须如实说明「committed and not rolled back」,
|
||||
// 绝不能误报整批回滚。
|
||||
if !strings.Contains(got, "committed and not rolled back") {
|
||||
t.Errorf("non-tx failure must state prior statements committed & not rolled back; got:\n%s", got)
|
||||
// DBA 模式(transactional=false)前序语句已 auto-commit 落地,绝不能误报「rolled back」。
|
||||
if strings.Contains(got, "rolled back") {
|
||||
t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "statements executed") {
|
||||
t.Errorf("failed run should NOT print success summary; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed errs.APIError」:
|
||||
// json 默认不再打 ok:true 假成功,而是返回 typed errs.* 错误(type=api / subtype=server_error、
|
||||
// exit=1)。失败位置在 message 的 "(at statement N of M)",前序是否落地/是否回滚写在 hint。
|
||||
// 本例无 BEGIN → 前序逐条 commit、未回滚(hint 含 "committed and not rolled back")。
|
||||
// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」:
|
||||
// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout,
|
||||
// 退出信号是 PartialFailureError(非零 exit)。rolled_back=false 因 CLI 永远 DBA 模式
|
||||
// (真机 boe 实证:失败前的语句已落地)。
|
||||
func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -649,36 +518,64 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
if p.Code != 1300002 {
|
||||
t.Errorf("code = %d, want 1300002", p.Code)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
if got := payload["statement_index"]; got != float64(1) {
|
||||
t.Errorf("statement_index = %v, want 1", got)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
if got := payload["error_code"]; got != float64(1300002) {
|
||||
t.Errorf("error_code = %v, want 1300002", got)
|
||||
}
|
||||
// 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "committed and not rolled back") {
|
||||
t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint)
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 2 of 2)") {
|
||||
t.Errorf("error_message missing statement locator: %q", msg)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
if got := payload["rolled_back"]; got != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
|
||||
}
|
||||
results, _ := payload["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
|
||||
}
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "already applied") {
|
||||
t.Errorf("note should warn prior statements persisted, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope,返回 data 块。
|
||||
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("envelope.ok = true, want false on partial failure")
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。
|
||||
// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
|
||||
func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -695,92 +592,26 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("single ERROR sentinel must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 1 of 1)") {
|
||||
t.Errorf("error_message missing locator: %q", msg)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("message missing locator: %q", p.Message)
|
||||
if got := payload["statement_index"]; got != float64(0) {
|
||||
t.Errorf("statement_index = %v, want 0", got)
|
||||
}
|
||||
// 第一条就失败、无落地 的语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "No statements were applied") {
|
||||
t.Errorf("hint should state nothing applied: %q", p.Hint)
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "no statements were applied") {
|
||||
t.Errorf("note should say nothing was applied, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
|
||||
// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。
|
||||
// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。
|
||||
func TestAppsDBExecute_TransactionFailureRolledBack(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
// BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR)
|
||||
"result": `[` +
|
||||
`{"sql_type":"BEGIN","data":"[]"},` +
|
||||
`{"sql_type":"CREATE_TABLE","data":"[]"},` +
|
||||
`{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 4 of 4)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
// 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。
|
||||
if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") {
|
||||
t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。
|
||||
func TestInferRolledBack_Cases(t *testing.T) {
|
||||
stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} }
|
||||
cases := []struct {
|
||||
name string
|
||||
completed []map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"autocommit single", []map[string]interface{}{stmt("INSERT")}, false},
|
||||
{"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true},
|
||||
{"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false},
|
||||
{"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true},
|
||||
{"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := inferRolledBack(c.completed); got != c.want {
|
||||
t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。
|
||||
func TestCellString_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -804,7 +635,6 @@ func TestCellString_AllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。
|
||||
func TestCodeString_Forms(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -826,7 +656,6 @@ func TestCodeString_Forms(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDmlVerb_AllVerbs 断言 dmlVerb 对 INSERT/UPDATE/DELETE/MERGE 的动词映射(大小写不敏感),非 DML 返回 affected。
|
||||
func TestDmlVerb_AllVerbs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"INSERT": "inserted",
|
||||
@@ -842,7 +671,6 @@ func TestDmlVerb_AllVerbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntOrZero_Cases 断言 intOrZero 对 JSON number 取整、对非数字 / nil 返回 0。
|
||||
func TestIntOrZero_Cases(t *testing.T) {
|
||||
if got := intOrZero(float64(5)); got != 5 {
|
||||
t.Errorf("intOrZero(5)=%d want 5", got)
|
||||
@@ -855,7 +683,6 @@ func TestIntOrZero_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrorSummary_Cases 断言 errorSummary 对空 / 非法 JSON / 带 code / 无 code 各情形生成 "message [code]" 文案。
|
||||
func TestErrorSummary_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
@@ -874,7 +701,6 @@ func TestErrorSummary_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseErrorSentinel_Cases 断言 parseErrorSentinel 解析 ERROR 哨兵 data 得到数值 code 与 message(含空 / 非法 / 空 message 回退)。
|
||||
func TestParseErrorSentinel_Cases(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in string
|
||||
@@ -896,7 +722,6 @@ func TestParseErrorSentinel_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsStructuredResult_Cases 断言 isStructuredResult 仅在首元素含 sql_type 时判为新结构化形态。
|
||||
func TestIsStructuredResult_Cases(t *testing.T) {
|
||||
if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) {
|
||||
t.Error("expected structured=true when sql_type present")
|
||||
@@ -909,7 +734,6 @@ func TestIsStructuredResult_Cases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeLegacyStatement_Cases 断言 normalizeLegacyStatement 把空 / null / 非 JSON 标为 OK、把 rows 数组标为 SELECT 并带 record_count。
|
||||
func TestNormalizeLegacyStatement_Cases(t *testing.T) {
|
||||
t.Run("empty -> OK", func(t *testing.T) {
|
||||
got := normalizeLegacyStatement("")
|
||||
@@ -940,7 +764,6 @@ func TestNormalizeLegacyStatement_Cases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCellString_MarshalFallback 断言 cellString 对 json.Marshal 拒绝的类型(如 complex)回退到 fmt %v。
|
||||
func TestCellString_MarshalFallback(t *testing.T) {
|
||||
// complex128 is not switch-handled and json.Marshal rejects it →
|
||||
// falls back to fmt.Sprintf("%v", v), which is deterministic for complex.
|
||||
@@ -949,7 +772,6 @@ func TestCellString_MarshalFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSingleStatementPretty_Branches 断言 renderSingleStatementPretty 对 SELECT/ERROR/DML/legacy OK/DDL 各分支的输出。
|
||||
func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -973,7 +795,6 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。
|
||||
func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -995,3 +816,35 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty
|
||||
// contract on a statement failure: stdout carries only the per-statement
|
||||
// human summary (no JSON envelope stacked after it), and the command still
|
||||
// exits non-zero via the partial-failure signal.
|
||||
func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"},
|
||||
factory, stdout)
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "✗") {
|
||||
t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `"ok"`) {
|
||||
t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsDBQuotaGet reports an app's database storage usage and object counts.
|
||||
//
|
||||
// GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时
|
||||
// 不输出(与 +file-quota-get 一致);tables / views 始终输出。
|
||||
var AppsDBQuotaGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-quota-get",
|
||||
Description: "Get an app's database storage usage",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-quota-get --app-id <app_id>",
|
||||
"Example: lark-cli apps +db-quota-get --app-id <app_id> --environment dev",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectLegacyEnvFlag(rctx)
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appDbQuotaPath(appID)).
|
||||
Desc("Get Miaoda app database storage usage").
|
||||
Params(map[string]interface{}{"env": dbEnv(rctx)})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := projectDbQuota(data)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderDbQuotaPretty(w, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectDbQuota 白名单投影 db quota 字段:只保留 storage_used_bytes / tables / views,
|
||||
// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段,避免无用字段消耗上下文。
|
||||
func projectDbQuota(data map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]}
|
||||
for _, k := range []string{"tables", "views"} {
|
||||
if v, ok := data[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
// 配额未对接(storage_quota_bytes=0/缺失)时不输出 quota / usage_percent。
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
out["storage_quota_bytes"] = data["storage_quota_bytes"]
|
||||
if v, ok := data["usage_percent"]; ok {
|
||||
out["usage_percent"] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderDbQuotaPretty 打 Storage(已用 / 配额 (百分比))与 Tables / Views 行(标签对齐 miaoda-cli)。
|
||||
func renderDbQuotaPretty(w io.Writer, data map[string]interface{}) {
|
||||
used := humanBytes(data["storage_used_bytes"])
|
||||
usage := used
|
||||
if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 {
|
||||
pct := ""
|
||||
if p, ok := numericAsFloat(data["usage_percent"]); ok {
|
||||
pct = fmt.Sprintf(" (%.1f%%)", p)
|
||||
}
|
||||
usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct)
|
||||
}
|
||||
pairs := [][2]string{{"Storage", usage}}
|
||||
if f, ok := numericAsFloat(data["tables"]); ok {
|
||||
pairs = append(pairs, [2]string{"Tables", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
if f, ok := numericAsFloat(data["views"]); ok {
|
||||
pairs = append(pairs, [2]string{"Views", fmt.Sprintf("%d", int64(f))})
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)"
|
||||
|
||||
// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id,
|
||||
// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。
|
||||
var AppsDBRecoveryDiff = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-diff",
|
||||
Description: "Preview restoring the database to a point in time (PITR diff)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-diff --app-id <app_id> --target 2h",
|
||||
"Apply with +db-recovery-apply --target <same> --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
preview, err := runRecoveryPreview(rctx, appID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := recoveryDiffOutput(target, preview)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRecoveryDiff(w, target, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes,
|
||||
// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
|
||||
var AppsDBRecoveryApply = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-apply",
|
||||
Description: "Restore the database to a point in time (overwrites current data, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes",
|
||||
"Preview first with +db-recovery-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
// 目标=当前态 → 后端短路 no_changes,不轮询。
|
||||
if strings.ToLower(common.GetString(submit, "status")) == "no_changes" {
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "no_changes", "target": target}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "restored", "ready":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("recovery to %s failed", target)
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "restored", "target": target}
|
||||
if n := intFromAny(final["restore_time_sec"]); n > 0 {
|
||||
out["restore_time_sec"] = n
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
if n, ok := out["restore_time_sec"].(int); ok {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
prid := common.GetString(submit, "preview_request_id")
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
case "success":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = "recovery preview failed"
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
type recoveryChange struct {
|
||||
Table string `json:"table"`
|
||||
Inserted interface{} `json:"inserted,omitempty"`
|
||||
Deleted interface{} `json:"deleted,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
DroppedAt string `json:"dropped_at,omitempty"`
|
||||
}
|
||||
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
Action: common.GetString(m, "action"),
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
|
||||
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
|
||||
changes, _ := out["changes"].([]recoveryChange)
|
||||
if len(changes) == 0 {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
|
||||
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
|
||||
}
|
||||
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
|
||||
}
|
||||
|
||||
// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。
|
||||
func describeRecoveryChange(c recoveryChange) string {
|
||||
switch c.Action {
|
||||
case "restore_table":
|
||||
return "table will be restored"
|
||||
case "drop_table":
|
||||
return "table will be dropped"
|
||||
case "alter_table":
|
||||
return "table will be altered"
|
||||
case "unavailable":
|
||||
if c.DroppedAt != "" {
|
||||
return "diff unavailable: " + c.DroppedAt
|
||||
}
|
||||
return "diff unavailable"
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if n := intFromAny(c.Inserted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("+%d rows", n))
|
||||
}
|
||||
if n := intFromAny(c.Deleted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("-%d rows", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
|
||||
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。
|
||||
//
|
||||
@@ -34,17 +34,15 @@ var AppsDBTableGet = common.Shortcut{
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "table", Desc: "table name", Required: true},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectLegacyEnvFlag(rctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(rctx.Str("table")) == "" {
|
||||
return appsValidationParamError("--table", "--table is required")
|
||||
}
|
||||
@@ -80,7 +78,7 @@ var AppsDBTableGet = common.Shortcut{
|
||||
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本;
|
||||
// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。
|
||||
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{"env": dbEnv(rctx)}
|
||||
params := map[string]interface{}{"env": rctx.Str("env")}
|
||||
if rctx.Format == "pretty" {
|
||||
params["format"] = "ddl"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbTableListHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --environment dev`"
|
||||
const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
|
||||
// AppsDBTableList lists tables in an app's database.
|
||||
//
|
||||
@@ -38,16 +38,15 @@ var AppsDBTableList = common.Shortcut{
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: append([]common.Flag{
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app id", Required: true},
|
||||
{Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return rejectLegacyEnvFlag(rctx)
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
@@ -111,7 +110,7 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
|
||||
|
||||
func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"env": dbEnv(rctx),
|
||||
"env": rctx.Str("env"),
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) {
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"},
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String())
|
||||
@@ -159,7 +159,7 @@ func TestAppsDBTableList_RequiresAppID(t *testing.T) {
|
||||
func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "dev",
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "dev",
|
||||
"--page-size", "50", "--page-token", "cursor-abc",
|
||||
"--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
@@ -212,7 +212,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) {
|
||||
func TestAppsDBTableList_RejectsBadEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsDBTableList,
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
|
||||
[]string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "env") {
|
||||
t.Fatalf("expected env enum rejection, got %v", err)
|
||||
}
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAppsEnvVarEnv = "dev"
|
||||
defaultAppsEnvVarScene = 2
|
||||
)
|
||||
|
||||
// AppsEnvVarList lists app environment variables without values by default.
|
||||
var AppsEnvVarList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+env-list",
|
||||
Description: "List app environment variables",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +env-list --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: "include-values", Type: "bool", Desc: "include environment variable values"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(envVarCollectionPath(appID)).
|
||||
Desc("List app environment variables").
|
||||
Body(buildEnvVarListBody(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
includeValues := rctx.Bool("include-values")
|
||||
data, err := rctx.CallAPITyped("POST", envVarCollectionPath(appID), nil, buildEnvVarListBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := normalizeEnvVarListOutput(data, includeValues)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsEnvVarSet sets one app environment variable. Values are never printed.
|
||||
var AppsEnvVarSet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+env-set",
|
||||
Description: "Set an app environment variable",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +env-set --app-id <app_id> --key FOO --value bar",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: "key", Desc: "environment variable key", Required: true},
|
||||
{Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm setting variables in online"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := requireEnvVarKey(rctx.Str("key")); err != nil {
|
||||
return err
|
||||
}
|
||||
if rctx.Str("value") == "" {
|
||||
return appsValidationParamError("--value", "--value is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
key, _ := requireEnvVarKey(rctx.Str("key"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(envVarCreateOrUpdatePath(appID)).
|
||||
Desc("Set app environment variable").
|
||||
Body(map[string]interface{}{
|
||||
"key": key,
|
||||
"env": envVarEnv(rctx),
|
||||
"value": "<redacted>",
|
||||
})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
env := envVarEnv(rctx)
|
||||
if env == "online" && !rctx.Bool("yes") {
|
||||
return errs.NewConfirmationRequiredError(
|
||||
errs.RiskWrite,
|
||||
"apps +env-set --environment online",
|
||||
"apps +env-set --environment online requires confirmation",
|
||||
).WithHint("add --yes to confirm")
|
||||
}
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := requireEnvVarKey(rctx.Str("key"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("POST", envVarCreateOrUpdatePath(appID), nil, map[string]interface{}{
|
||||
"key": key,
|
||||
"env": env,
|
||||
"value": rctx.Str("value"),
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(err, envVarMutationHint(err))
|
||||
}
|
||||
action := envVarStringAny(data, "action")
|
||||
if action == "" {
|
||||
action = "set"
|
||||
}
|
||||
rctx.OutFormat(map[string]interface{}{
|
||||
"key": key,
|
||||
"env": env,
|
||||
"action": action,
|
||||
}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsEnvVarDelete deletes one or more app environment variables.
|
||||
var AppsEnvVarDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+env-delete",
|
||||
Description: "Delete app environment variables",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +env-delete --app-id <app_id> --key FOO --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireEnvVarKeys(rctx.StrArray("key"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
keys, _ := requireEnvVarKeys(rctx.StrArray("key"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(envVarDeletePath(appID)).
|
||||
Desc("Delete app environment variables").
|
||||
Body(buildEnvVarDeleteBody(envVarEnv(rctx), keys))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keys, err := requireEnvVarKeys(rctx.StrArray("key"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env := envVarEnv(rctx)
|
||||
data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys))
|
||||
if err != nil {
|
||||
return withAppsHint(err, envVarMutationHint(err))
|
||||
}
|
||||
deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys")
|
||||
if len(deletedKeys) == 0 {
|
||||
deletedKeys = keys
|
||||
}
|
||||
rctx.OutFormat(map[string]interface{}{
|
||||
"env": env,
|
||||
"deleted_keys": deletedKeys,
|
||||
}, nil, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func envVarEnv(rctx *common.RuntimeContext) string {
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
return defaultAppsEnvVarEnv
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func envVarCollectionPath(appID string) string {
|
||||
return appScopedPath(appID, "env_vars")
|
||||
}
|
||||
|
||||
func envVarCreateOrUpdatePath(appID string) string {
|
||||
return appScopedPath(appID, "create_or_update_env_var")
|
||||
}
|
||||
|
||||
func envVarDeletePath(appID string) string {
|
||||
return appScopedPath(appID, "delete_env_vars")
|
||||
}
|
||||
|
||||
func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": envVarEnv(rctx),
|
||||
"scene": defaultAppsEnvVarScene,
|
||||
}
|
||||
}
|
||||
|
||||
func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": env,
|
||||
"keys": keys,
|
||||
}
|
||||
}
|
||||
|
||||
func envVarMutationHint(err error) string {
|
||||
if isEnvVarNotModifiableError(err) {
|
||||
return "this environment variable is platform-managed and cannot be modified; remove protected keys from --key and retry only with user-defined variables"
|
||||
}
|
||||
return appIDListHint
|
||||
}
|
||||
|
||||
func isEnvVarNotModifiableError(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(p.Message), "not modifiable")
|
||||
}
|
||||
|
||||
func requireEnvVarKey(raw string) (string, error) {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
return "", appsValidationParamError("--key", "--key is required")
|
||||
}
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
return "", appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func requireEnvVarKeys(raw []string) ([]string, error) {
|
||||
keys := cleanRepeatedStrings(raw)
|
||||
if len(keys) == 0 {
|
||||
return nil, appsValidationParamError("--key", "--key is required")
|
||||
}
|
||||
for _, key := range keys {
|
||||
if !envKeyPattern.MatchString(key) {
|
||||
return nil, appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
type envVarListOutput struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
PageToken string `json:"page_token"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
func normalizeEnvVarListOutput(data map[string]interface{}, includeValues bool) envVarListOutput {
|
||||
src := envVarResponseMap(data)
|
||||
return envVarListOutput{
|
||||
Items: normalizeEnvVarItems(envVarItemsRaw(src), includeValues),
|
||||
PageToken: envVarStringAny(src, "page_token", "next_page_token", "nextPageToken"),
|
||||
HasMore: envVarBoolAny(src, "has_more", "hasMore"),
|
||||
}
|
||||
}
|
||||
|
||||
func envVarResponseMap(data map[string]interface{}) map[string]interface{} {
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
return nested
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func envVarItemsRaw(data map[string]interface{}) interface{} {
|
||||
if raw := data["env_vars"]; raw != nil {
|
||||
return raw
|
||||
}
|
||||
if raw := data["envVars"]; raw != nil {
|
||||
return raw
|
||||
}
|
||||
return data["items"]
|
||||
}
|
||||
|
||||
func normalizeEnvVarItems(raw interface{}, includeValues bool) []map[string]interface{} {
|
||||
switch typed := raw.(type) {
|
||||
case []interface{}:
|
||||
out := make([]map[string]interface{}, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, filterEnvVarItem(m, includeValues))
|
||||
}
|
||||
return out
|
||||
case map[string]interface{}:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
item := map[string]interface{}{"key": key}
|
||||
if includeValues {
|
||||
item["value"] = typed[key]
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(item))
|
||||
for key, value := range item {
|
||||
if key == "value" && !includeValues {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envVarListSchema(includeValues bool) appsOutputSchema {
|
||||
columns := []appsOutputColumn{
|
||||
{Key: "key"},
|
||||
{Key: "env"},
|
||||
}
|
||||
if includeValues {
|
||||
columns = append(columns, appsOutputColumn{Key: "value"})
|
||||
}
|
||||
return appsOutputSchema{Columns: columns, Strict: true}
|
||||
}
|
||||
|
||||
func envVarStringAny(data map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func envVarStringSliceAny(data map[string]interface{}, keys ...string) []string {
|
||||
for _, key := range keys {
|
||||
switch raw := data[key].(type) {
|
||||
case []string:
|
||||
return append([]string(nil), raw...)
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if value, ok := item.(string); ok {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envVarBoolAny(data map[string]interface{}, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(bool); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -62,9 +62,8 @@ var AppsEnvPull = common.Shortcut{
|
||||
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(envPullVarsPath(appID)).
|
||||
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
Desc("Pull app startup env vars into the local .env.local file").
|
||||
Body(envPullVarsBody()).
|
||||
Set("project_path", projectPath).
|
||||
Set("env_file", envFile)
|
||||
},
|
||||
@@ -81,7 +80,8 @@ var AppsEnvPull = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, nil)
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
@@ -116,16 +116,6 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func envPullVarsPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func envPullVarsBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": "dev",
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
@@ -160,19 +150,13 @@ func checkEnvPullTarget(envFile string) error {
|
||||
|
||||
func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) {
|
||||
raw := data["env_vars"]
|
||||
if raw == nil {
|
||||
raw = data["envVars"]
|
||||
}
|
||||
if raw == nil {
|
||||
if nested, ok := data["data"].(map[string]interface{}); ok {
|
||||
raw = nested["env_vars"]
|
||||
if raw == nil {
|
||||
raw = nested["envVars"]
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
|
||||
var skippedKeys []string
|
||||
@@ -219,7 +203,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull
|
||||
}
|
||||
return out, info, skippedKeys, nil
|
||||
default:
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries")
|
||||
return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars must be an object or array of key/value entries")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -32,11 +31,6 @@ func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnvPullBody(t *testing.T, req *http.Request) {
|
||||
t.Helper()
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
|
||||
}
|
||||
|
||||
func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
@@ -261,7 +255,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) {
|
||||
func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
@@ -278,9 +272,6 @@ func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) {
|
||||
if !strings.Contains(got, `/open-apis/spark/v1/apps/app_x/env_vars`) {
|
||||
t.Fatalf("dry-run missing endpoint: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"env": "dev"`) || strings.Contains(got, `"include_values"`) {
|
||||
t.Fatalf("dry-run must include only env=dev in the request body: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) {
|
||||
t.Fatalf("dry-run must include resolved env file path: %s", got)
|
||||
}
|
||||
@@ -292,9 +283,6 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
@@ -562,36 +550,6 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
RawBody: []byte("[]"),
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface{}) {
|
||||
t.Helper()
|
||||
if req.URL.RawQuery != "" {
|
||||
t.Fatalf("query should be empty, got %q", req.URL.RawQuery)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("body = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func expectedEnvVarSceneJSON() float64 {
|
||||
return float64(defaultAppsEnvVarScene)
|
||||
}
|
||||
|
||||
func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
||||
}
|
||||
if !envelope.OK {
|
||||
t.Fatalf("expected ok envelope, got %s", stdout)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
func requireEnvVarValidationProblem(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
p := requireAppsProblem(t, err, errs.CategoryValidation)
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("validation subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validation.Param != param {
|
||||
t.Fatalf("validation param = %q, want %q", validation.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()})
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"envVars": []interface{}{
|
||||
map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) {
|
||||
t.Fatalf("stdout must not expose values by default: %s", got)
|
||||
}
|
||||
data := decodeEnvVarEnvelopeData(t, got)
|
||||
items, ok := data["items"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, ok := items[0].(map[string]interface{})
|
||||
if !ok || item["key"] != "SECRET_TOKEN" {
|
||||
t.Fatalf("item = %#v, want SECRET_TOKEN", items[0])
|
||||
}
|
||||
if _, ok := item["value"]; ok {
|
||||
t.Fatalf("item must not contain value by default: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()})
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"envVars": []interface{}{
|
||||
map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "super-secret") {
|
||||
t.Fatalf("stdout should include values when requested: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") {
|
||||
t.Fatalf("expected unknown -e shorthand, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsEnvVarList, []string{
|
||||
"+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() {
|
||||
t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"envVars": []interface{}{
|
||||
map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarList, []string{
|
||||
"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.HasPrefix(got, "key") {
|
||||
t.Fatalf("pretty output should start with key column, got:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"API_HOST", "online", "https://example.com"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) {
|
||||
t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout)
|
||||
|
||||
p := requireAppsProblem(t, err, errs.CategoryConfirmation)
|
||||
if p.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("confirmation subtype = %q, want %q", p.Subtype, errs.SubtypeConfirmationRequired)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "add --yes") {
|
||||
t.Fatalf("confirmation hint missing --yes guidance: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "super-secret") {
|
||||
t.Fatalf("dry-run must redact value: %s", got)
|
||||
}
|
||||
for _, want := range []string{`"method": "POST"`, `/open-apis/spark/v1/apps/app_x/create_or_update_env_var`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("dry-run missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
}
|
||||
if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "<redacted>" || dryRun.API[0].Body["key"] != "SECRET_TOKEN" {
|
||||
t.Fatalf("dry-run body = %#v, want redacted value and key", dryRun.API)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/create_or_update_env_var",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"action": "updated"}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["key"] != "SECRET_TOKEN" || sent["env"] != "online" || sent["value"] != "super-secret" {
|
||||
t.Fatalf("body = %#v, want real online value", sent)
|
||||
}
|
||||
got := stdout.String()
|
||||
if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) {
|
||||
t.Fatalf("stdout must not echo value: %s", got)
|
||||
}
|
||||
for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "updated"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) {
|
||||
if AppsEnvVarDelete.Risk != "high-risk-write" {
|
||||
t.Fatalf("risk = %q, want high-risk-write", AppsEnvVarDelete.Risk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"deleted_keys": []interface{}{"SECRET_ONE", "SECRET_TWO"}}},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if sent["env"] != "online" {
|
||||
t.Fatalf("body.env = %v, want online", sent["env"])
|
||||
}
|
||||
keys, ok := sent["keys"].([]interface{})
|
||||
if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" {
|
||||
t.Fatalf("body.keys = %#v, want SECRET_ONE/SECRET_TWO", sent["keys"])
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"env": "online"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400000072,
|
||||
"msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable",
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Code != 400000072 {
|
||||
t.Fatalf("code = %d, want 400000072", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") {
|
||||
t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
got := stdout.String()
|
||||
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, got)
|
||||
}
|
||||
if len(dryRun.API) != 1 || dryRun.API[0].Method != "POST" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/delete_env_vars" {
|
||||
t.Fatalf("dry-run api = %#v", dryRun.API)
|
||||
}
|
||||
if dryRun.API[0].Body["env"] != "online" {
|
||||
t.Fatalf("dry-run body.env = %v, want online", dryRun.API[0].Body["env"])
|
||||
}
|
||||
keys, ok := dryRun.API[0].Body["keys"].([]interface{})
|
||||
if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" {
|
||||
t.Fatalf("dry-run body.keys = %#v, want SECRET_ONE/SECRET_TWO", dryRun.API[0].Body["keys"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--environment")
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") {
|
||||
t.Fatalf("expected old --env to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+env-set", "--app-id", "app_x", "--key", "bad-key",
|
||||
"--value", "super-secret", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--key")
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--key", "bad-key",
|
||||
"--yes", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--key")
|
||||
}
|
||||
@@ -14,9 +14,6 @@ func TestAppsShortcutsHaveExamples(t *testing.T) {
|
||||
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Hidden {
|
||||
continue
|
||||
}
|
||||
hasExample := false
|
||||
for _, tip := range s.Tips {
|
||||
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
|
||||
@@ -53,62 +50,3 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvTipsCoverConfirmations(t *testing.T) {
|
||||
envSet := requireShortcutForExamples(t, "+env-set")
|
||||
if !tipsContainAll(envSet.Tips, "--environment online", "--yes") {
|
||||
t.Fatalf("+env-set tips must include an online write example with --environment online --yes: %#v", envSet.Tips)
|
||||
}
|
||||
|
||||
envDelete := requireShortcutForExamples(t, "+env-delete")
|
||||
if !tipsContainAll(envDelete.Tips, "--yes") {
|
||||
t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"+log-list",
|
||||
"+log-get",
|
||||
"+trace-list",
|
||||
"+trace-get",
|
||||
"+metric-list",
|
||||
"+analytics-list",
|
||||
} {
|
||||
shortcut := requireShortcutForExamples(t, cmd)
|
||||
if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") {
|
||||
t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireShortcutForExamples(t *testing.T, command string) shortcutForExamples {
|
||||
t.Helper()
|
||||
for _, sc := range Shortcuts() {
|
||||
if sc.Command == command {
|
||||
return shortcutForExamples{Tips: sc.Tips}
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing shortcut %s", command)
|
||||
return shortcutForExamples{}
|
||||
}
|
||||
|
||||
type shortcutForExamples struct {
|
||||
Tips []string
|
||||
}
|
||||
|
||||
func tipsContainAll(tips []string, needles ...string) bool {
|
||||
for _, tip := range tips {
|
||||
ok := true
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(tip, needle) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileDelete batch-deletes files by remote path(high-risk-write,框架自动注入 --yes 确认)。
|
||||
//
|
||||
// POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST
|
||||
// (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths
|
||||
// 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 path)。
|
||||
// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error,不翻成非 0 退出码(lark-cli 信封语义)。
|
||||
var AppsFileDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-delete",
|
||||
Description: "Delete one or more files by remote path (batch)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-delete --app-id <app_id> --path /1858537546760216.png --yes",
|
||||
"Repeat --path for batch delete.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cleanDeletePaths(rctx)) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required (at least one remote path)").WithParam("--path")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFileBatchRemovePath(appID)).
|
||||
Desc("Batch delete Miaoda app files").
|
||||
Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := cleanDeletePaths(rctx)
|
||||
data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results := projectDeleteResults(data["results"], paths)
|
||||
out := map[string]interface{}{"results": results}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderFileDeletePretty(w, results)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// cleanDeletePaths 取 --path 切片,trim 去空。
|
||||
func cleanDeletePaths(rctx *common.RuntimeContext) []string {
|
||||
out := make([]string, 0)
|
||||
for _, p := range rctx.StrSlice("path") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// projectDeleteResults 把后端 results[] 按下标 zip 回请求 paths,回填 path,
|
||||
// 失败项把 error_code 包成 {code,message} 便于消费。
|
||||
func projectDeleteResults(raw interface{}, inputs []string) []map[string]interface{} {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]map[string]interface{}, 0, len(inputs))
|
||||
for i, input := range inputs {
|
||||
var r map[string]interface{}
|
||||
if i < len(arr) {
|
||||
r, _ = arr[i].(map[string]interface{})
|
||||
}
|
||||
status := "ok"
|
||||
if r != nil && common.GetString(r, "status") != "" {
|
||||
status = common.GetString(r, "status")
|
||||
}
|
||||
item := map[string]interface{}{"status": status, "path": input}
|
||||
if status == "ok" {
|
||||
if r != nil {
|
||||
if f, ok := r["file"].(map[string]interface{}); ok {
|
||||
item["file_name"] = common.GetString(f, "file_name")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
code := ""
|
||||
if r != nil {
|
||||
code = common.GetString(r, "error_code")
|
||||
}
|
||||
if code == "" {
|
||||
code = "DELETE_FAILED"
|
||||
}
|
||||
item["error"] = map[string]interface{}{
|
||||
"code": code,
|
||||
"message": deleteErrorMessage(code, input),
|
||||
}
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deleteErrorMessage 据 error_code 生成删除失败文案:FILE_NOT_FOUND 提示文件不存在,其余统一删除失败。
|
||||
func deleteErrorMessage(code, path string) string {
|
||||
if code == "FILE_NOT_FOUND" {
|
||||
return fmt.Sprintf("File '%s' does not exist", path)
|
||||
}
|
||||
return fmt.Sprintf("Failed to delete '%s'", path)
|
||||
}
|
||||
|
||||
// renderFileDeletePretty 逐项打 ✓ / ✗,末行汇总 deleted 计数。
|
||||
func renderFileDeletePretty(w io.Writer, results []map[string]interface{}) {
|
||||
okCount := 0
|
||||
for _, r := range results {
|
||||
path := common.GetString(r, "path")
|
||||
if common.GetString(r, "status") == "ok" {
|
||||
fmt.Fprintf(w, "✓ %s\n", path)
|
||||
okCount++
|
||||
continue
|
||||
}
|
||||
code := ""
|
||||
if e, ok := r["error"].(map[string]interface{}); ok {
|
||||
code = common.GetString(e, "code")
|
||||
}
|
||||
fmt.Fprintf(w, "✗ %s (%s)\n", path, code)
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d/%d deleted\n", okCount, len(results))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user