mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
34 Commits
feat/confi
...
v1.0.58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a09c8957 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 | ||
|
|
1c92ed8841 | ||
|
|
644c3c77dd | ||
|
|
bd898a1d74 | ||
|
|
898e6d4b3b | ||
|
|
7df37ed715 | ||
|
|
3f9ace8af5 | ||
|
|
b3514e5519 | ||
|
|
b46e60c156 | ||
|
|
d71bab0061 | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 | ||
|
|
cb54bea00d | ||
|
|
036e5799d3 | ||
|
|
c4106f50b2 | ||
|
|
736b131cdf | ||
|
|
5efaf65aec | ||
|
|
0991da7446 | ||
|
|
80bea45c6a | ||
|
|
c775cb4360 | ||
|
|
824aa9edf8 | ||
|
|
9d4ae94394 | ||
|
|
bba13cfe0f | ||
|
|
815cdb8f1c | ||
|
|
4f3ae0c71a | ||
|
|
96d70143c5 | ||
|
|
83db15907f |
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -70,6 +71,7 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -87,6 +89,23 @@ jobs:
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,8 +128,28 @@ jobs:
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Write public content metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
mkdir -p .tmp/quality-gate
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"title": os.environ.get("PR_TITLE", ""),
|
||||
"body": os.environ.get("PR_BODY", ""),
|
||||
"branch": os.environ.get("PR_BRANCH", ""),
|
||||
}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
- name: Run CLI deterministic gate
|
||||
run: make quality-gate
|
||||
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -220,7 +259,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -241,7 +280,7 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -333,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -345,6 +384,7 @@ jobs:
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -361,6 +401,7 @@ jobs:
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
107
.github/workflows/semantic-review.yml
vendored
107
.github/workflows/semantic-review.yml
vendored
@@ -47,10 +47,13 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -71,11 +74,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -85,31 +88,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -118,12 +134,17 @@ 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");
|
||||
return;
|
||||
}
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -255,10 +276,13 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -279,11 +303,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -293,31 +317,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -326,12 +363,22 @@ 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");
|
||||
return;
|
||||
}
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
@@ -383,6 +430,10 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,6 +7,11 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -46,3 +51,4 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -2,6 +2,82 @@
|
||||
|
||||
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
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **api**: Align API success envelopes (#1489)
|
||||
- **base**: Reject out-of-range pagination flags (#1495)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Retire legacy error envelopes and enforce typed contract (#1449)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skills**: Soften lark-doc style guidance (#1463)
|
||||
|
||||
### Build
|
||||
|
||||
- Add CI quality gate with semantic review
|
||||
|
||||
## [v1.0.55] - 2026-06-16
|
||||
|
||||
### Features
|
||||
@@ -1189,6 +1265,9 @@ 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
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
|
||||
|
||||
5
Makefile
5
Makefile
@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
|
||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
@@ -69,7 +70,8 @@ integration-test: build
|
||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||
|
||||
quality-gate: build
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
@@ -89,6 +91,7 @@ quality-gate: build
|
||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||
|
||||
install: build
|
||||
|
||||
@@ -26,6 +26,7 @@ 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)
|
||||
@@ -55,4 +56,17 @@ 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,6 +96,34 @@ 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) })
|
||||
|
||||
132
events/im/card_action.go
Normal file
132
events/im/card_action.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||
type CardActionTriggerOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||
}
|
||||
|
||||
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Operator struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"operator"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
Action struct {
|
||||
Tag string `json:"tag"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
Name string `json:"name"`
|
||||
FormValue map[string]interface{} `json:"form_value"`
|
||||
InputValue string `json:"input_value"`
|
||||
Option string `json:"option"`
|
||||
Options []string `json:"options"`
|
||||
Checked bool `json:"checked"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"action"`
|
||||
Context struct {
|
||||
OpenMessageID string `json:"open_message_id"`
|
||||
OpenChatID string `json:"open_chat_id"`
|
||||
} `json:"context"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||
}
|
||||
|
||||
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||
|
||||
out := &CardActionTriggerOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
OperatorID: envelope.Event.Operator.OpenID,
|
||||
MessageID: envelope.Event.Context.OpenMessageID,
|
||||
ChatID: envelope.Event.Context.OpenChatID,
|
||||
Host: envelope.Event.Host,
|
||||
Token: envelope.Event.Token,
|
||||
ActionTag: envelope.Event.Action.Tag,
|
||||
ActionValue: actionValue,
|
||||
ActionName: envelope.Event.Action.Name,
|
||||
FormValue: formValue,
|
||||
InputValue: envelope.Event.Action.InputValue,
|
||||
Option: envelope.Event.Action.Option,
|
||||
Options: options,
|
||||
Checked: envelope.Event.Action.Checked,
|
||||
Timezone: envelope.Event.Action.Timezone,
|
||||
}
|
||||
|
||||
if out.MessageID != "" && rt != nil {
|
||||
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// fetchCardUserDSL gets the card message content via message get API.
|
||||
// Returns empty string on any failure — never blocks event consumption.
|
||||
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Data.Items[0].Body.Content
|
||||
}
|
||||
|
||||
func marshalToString(m map[string]interface{}) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
432
events/im/card_action_test.go
Normal file
432
events/im/card_action_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("card.action.trigger")
|
||||
if !ok {
|
||||
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("card.action.trigger must set Schema.Custom")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("card.action.trigger must set Process")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Button(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_btn_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_operator"},
|
||||
"token": "c-token-btn",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "approve"},
|
||||
"name": "approve_btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_msg_001",
|
||||
"open_chat_id": "oc_chat_001"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Type != "card.action.trigger" {
|
||||
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_btn_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" {
|
||||
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
if out.ActionValue != `{"key":"approve"}` {
|
||||
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||
}
|
||||
if out.ActionName != "approve_btn" {
|
||||
t.Errorf("ActionName = %q", out.ActionName)
|
||||
}
|
||||
if out.Token != "c-token-btn" {
|
||||
t.Errorf("Token = %q", out.Token)
|
||||
}
|
||||
if out.MessageID != "om_msg_001" {
|
||||
t.Errorf("MessageID = %q", out.MessageID)
|
||||
}
|
||||
if out.ChatID != "oc_chat_001" {
|
||||
t.Errorf("ChatID = %q", out.ChatID)
|
||||
}
|
||||
if out.Host != "im_message" {
|
||||
t.Errorf("Host = %q", out.Host)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_form_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_form_user"},
|
||||
"token": "c-token-form",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "submit_btn",
|
||||
"form_value": {"name": "test-user", "reason": "testing"},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_form_001",
|
||||
"open_chat_id": "oc_chat_002"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||
t.Errorf("FormValue = %q", out.FormValue)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ms_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_ms_user"},
|
||||
"token": "c-token-ms",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "multi_select_static",
|
||||
"value": {},
|
||||
"name": "multi_select",
|
||||
"options": ["opt_1", "opt_3"],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_ms_001",
|
||||
"open_chat_id": "oc_chat_003"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Options != "opt_1,opt_3" {
|
||||
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||
}
|
||||
if out.ActionTag != "multi_select_static" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Input(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_input_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469276"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_input_user"},
|
||||
"token": "c-token-input",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "input",
|
||||
"value": {},
|
||||
"name": "text_input",
|
||||
"input_value": "hello world",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_input_001",
|
||||
"open_chat_id": "oc_chat_004"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.InputValue != "hello world" {
|
||||
t.Errorf("InputValue = %q", out.InputValue)
|
||||
}
|
||||
if out.ActionTag != "input" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_date_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469277"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_date_user"},
|
||||
"token": "c-token-date",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "date_picker",
|
||||
"value": {},
|
||||
"name": "date_selector",
|
||||
"option": "2024-04-01 +0800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_date_001",
|
||||
"open_chat_id": "oc_chat_005"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Option != "2024-04-01 +0800" {
|
||||
t.Errorf("Option = %q", out.Option)
|
||||
}
|
||||
if out.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("Timezone = %q", out.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ok",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469278"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user"},
|
||||
"token": "c-token-mg",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "click"},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_001",
|
||||
"open_chat_id": "oc_chat_mg"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||
mock := &mockAPIClient{resp: `{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [{
|
||||
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent == "" {
|
||||
t.Error("CardContent should not be empty when message get succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ec",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469279"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user2"},
|
||||
"token": "c-token-mg2",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_002",
|
||||
"open_chat_id": "oc_chat_mg2"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_fail",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469280"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user3"},
|
||||
"token": "c-token-mg3",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_003",
|
||||
"open_chat_id": "oc_chat_mg3"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{errResp: true}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_no_msg",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469281"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_no_msg"},
|
||||
"token": "c-token-nm",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "",
|
||||
"open_chat_id": "oc_chat_nm"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
type mockAPIClient struct {
|
||||
resp string
|
||||
errResp bool
|
||||
}
|
||||
|
||||
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if m.errResp {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return json.RawMessage(m.resp), nil
|
||||
}
|
||||
|
||||
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out CardActionTriggerOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
var content string
|
||||
if msg.MessageType == "interactive" {
|
||||
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
|
||||
} else {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
|
||||
@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
{
|
||||
Key: "card.action.trigger",
|
||||
DisplayName: "Card action",
|
||||
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
EventType: "card.action.trigger",
|
||||
SubscriptionType: event.SubTypeCallback,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||
},
|
||||
Process: processCardAction,
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
SingleConsumer: true,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
23
events/task/native.go
Normal file
23
events/task/native.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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",
|
||||
}
|
||||
32
events/task/preconsume.go
Normal file
32
events/task/preconsume.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// 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
|
||||
}
|
||||
119
events/task/preconsume_test.go
Normal file
119
events/task/preconsume_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
33
events/task/register.go
Normal file
33
events/task/register.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
95
events/task/register_test.go
Normal file
95
events/task/register_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
|
||||
}
|
||||
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions rejects world/group-writable modes (always) and
|
||||
// world/group-readable modes (unless allowReadableByOthers is true, which
|
||||
// exec commands typically need for their usual 755 mode).
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
info, err := vfs.Stat(effectivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
mode := info.Mode().Perm()
|
||||
|
||||
if mode&0o002 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o020 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if allowReadableByOthers {
|
||||
return nil
|
||||
}
|
||||
if mode&0o004 != 0 {
|
||||
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
if mode&0o040 != 0 {
|
||||
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,22 @@
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
|
||||
func checkOwnerUID(path, label string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
|
||||
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
|
||||
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
|
||||
if _, err := vfs.Stat(effectivePath); err != nil {
|
||||
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
33
internal/binding/audit_windows_test.go
Normal file
33
internal/binding/audit_windows_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build windows
|
||||
|
||||
package binding
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "secrets-getter.cmd")
|
||||
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
|
||||
t.Fatalf("write temp command: %v", err)
|
||||
}
|
||||
|
||||
got, err := AssertSecurePath(AuditParams{
|
||||
TargetPath: p,
|
||||
Label: "exec provider command",
|
||||
AllowInsecurePath: false,
|
||||
AllowReadableByOthers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
|
||||
}
|
||||
if got != p {
|
||||
t.Errorf("got %q, want %q", got, p)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ type EnumOption struct {
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// — from enum (with descriptions backfilled from options when the field carries
|
||||
// both forms), or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
@@ -122,9 +123,14 @@ func (f Field) EnumOptions() []EnumOption {
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
// key by raw literal so enum "1" and option 1 align across JSON types
|
||||
desc := make(map[string]string, len(f.Options))
|
||||
for _, o := range f.Options {
|
||||
desc[fmt.Sprintf("%v", o.Value)] = o.Description
|
||||
}
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v})
|
||||
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
|
||||
@@ -80,6 +80,39 @@ func TestField_EnumOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
|
||||
// enum is the value set; descriptions backfilled from options, empty where absent
|
||||
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "6", Description: "subject"},
|
||||
}}
|
||||
want := []EnumOption{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "3", Description: ""},
|
||||
{Value: "4", Description: ""},
|
||||
{Value: "6", Description: "subject"},
|
||||
}
|
||||
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// enum values stored as strings match option values stored as numbers
|
||||
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
|
||||
{Value: 1, Description: "one"},
|
||||
{Value: 2, Description: "two"},
|
||||
}}
|
||||
wantI := []EnumOption{
|
||||
{Value: int64(1), Description: "one"},
|
||||
{Value: int64(2), Description: "two"},
|
||||
{Value: int64(10), Description: ""},
|
||||
}
|
||||
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
|
||||
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Enum_NumberAndBoolean(t *testing.T) {
|
||||
// number: string-stored floats coerced to float64 and numerically sorted
|
||||
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
|
||||
|
||||
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
92
internal/qualitygate/cmd/comment-audit/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type eventPayload struct {
|
||||
Comment *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"comment"`
|
||||
Review *struct {
|
||||
Body string `json:"body"`
|
||||
} `json:"review"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
|
||||
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
|
||||
flag.Parse()
|
||||
|
||||
if *eventPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
|
||||
os.Exit(2)
|
||||
}
|
||||
body, err := commentBody(*eventPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
diags := diagnostics(publiccontent.ScanComment(*kind, body))
|
||||
if len(diags) > 0 {
|
||||
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
|
||||
}
|
||||
report.Print(os.Stderr, diags)
|
||||
os.Exit(report.ExitCode(diags))
|
||||
}
|
||||
|
||||
func auditFailureSummary(count int) string {
|
||||
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
|
||||
}
|
||||
|
||||
func commentBody(path string) (string, error) {
|
||||
safePath, err := validate.SafeInputPath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
|
||||
WithParam("--event").
|
||||
WithCause(err)
|
||||
}
|
||||
data, err := vfs.ReadFile(safePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload eventPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case payload.Comment != nil:
|
||||
return payload.Comment.Body, nil
|
||||
case payload.Review != nil:
|
||||
return payload.Review.Body, nil
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func diagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||
out := make([]report.Diagnostic, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, report.Diagnostic{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
70
internal/qualitygate/cmd/comment-audit/main_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"clean comment"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(origDir)
|
||||
})
|
||||
|
||||
got, err := commentBody("event.json")
|
||||
if err != nil {
|
||||
t.Fatalf("commentBody() error = %v", err)
|
||||
}
|
||||
if got != "clean comment" {
|
||||
t.Fatalf("comment body = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "event.json")
|
||||
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := commentBody(path)
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if err == nil || !ok {
|
||||
t.Fatalf("commentBody(%q) error = %v, want unsafe path validation error", path, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("commentBody(%q) problem = %#v, want invalid argument validation", path, problem)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Param != "--event" {
|
||||
t.Fatalf("commentBody(%q) error = %v, want --event validation param", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditFailureSummaryStatesPostPublicationAudit(t *testing.T) {
|
||||
got := auditFailureSummary(2)
|
||||
want := "post-publication audit found public content findings: 2"
|
||||
if got != want {
|
||||
t.Fatalf("auditFailureSummary() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(path, data string) error {
|
||||
return os.WriteFile(path, []byte(data), 0o644)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/qualitygate/rules"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -41,6 +42,7 @@ func runCheck(args []string) int {
|
||||
fs.StringVar(&opts.FactsOut, "facts-out", "", "write facts JSON to this path")
|
||||
fs.StringVar(&opts.ManifestPath, "manifest", "", "hand-authored command manifest JSON")
|
||||
fs.StringVar(&opts.CommandIndexPath, "command-index", "", "full command index JSON")
|
||||
fs.StringVar(&opts.PublicContentMetadataPath, "public-content-metadata", "", "PR title/body metadata JSON for public content checks")
|
||||
fs.BoolVar(&printLegacyCommandCandidates, "print-legacy-command-candidates", false, "print current non-kebab-case hand-authored command candidates")
|
||||
fs.BoolVar(&printLegacyFlagCandidates, "print-legacy-flag-candidates", false, "print current non-kebab-case flag candidates")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -48,6 +50,15 @@ func runCheck(args []string) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
if opts.PublicContentMetadataPath != "" {
|
||||
safePath, err := validate.SafeInputPath(opts.PublicContentMetadataPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "quality-gate check: --public-content-metadata: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
opts.PublicContentMetadataPath = safePath
|
||||
}
|
||||
|
||||
if opts.ManifestPath == "" || opts.CommandIndexPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "quality-gate check: --manifest and --command-index are required")
|
||||
return 2
|
||||
|
||||
@@ -37,6 +37,37 @@ func TestCheckRequiresManifestInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAcceptsPublicContentMetadataFlag(t *testing.T) {
|
||||
code, stderr := runCheckCaptureStderr(t, []string{
|
||||
"--repo", t.TempDir(),
|
||||
"--cli-bin", "./lark-cli",
|
||||
"--public-content-metadata", ".tmp/quality-gate/pr.json",
|
||||
})
|
||||
if code != 2 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "flag provided but not defined") {
|
||||
t.Fatalf("public content metadata flag was not registered: %s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--manifest and --command-index are required") {
|
||||
t.Fatalf("stderr = %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRejectsUnsafePublicContentMetadataPath(t *testing.T) {
|
||||
code, stderr := runCheckCaptureStderr(t, []string{
|
||||
"--repo", t.TempDir(),
|
||||
"--cli-bin", "./lark-cli",
|
||||
"--public-content-metadata", filepath.Join(t.TempDir(), "pr.json"),
|
||||
})
|
||||
if code != 2 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "--public-content-metadata") || !strings.Contains(stderr, "--file") {
|
||||
t.Fatalf("stderr = %s, want unsafe public content metadata path error", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsManifestReadErrorsWithFlagName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifestPath := filepath.Join(dir, "command-manifest.json")
|
||||
|
||||
@@ -56,6 +56,14 @@ func run(args []string) int {
|
||||
_ = semantic.WriteMarkdown(markdownOut, decision)
|
||||
return 0
|
||||
}
|
||||
if reviewPath == "" && !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
decision := finalizeDecision(block, waiverDiags, semantic.Decision{})
|
||||
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return decisionExitCode(decision)
|
||||
}
|
||||
review, err := semantic.LoadOrReviewWithConfig(context.Background(), f, reviewPath, modelConfig)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
@@ -72,6 +80,15 @@ func run(args []string) int {
|
||||
return 0
|
||||
}
|
||||
decision := semantic.DecideWithWaivers(f, review, policy, waivers)
|
||||
decision = finalizeDecision(block, waiverDiags, decision)
|
||||
if err := writeSemanticOutputs(decisionOut, markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
return decisionExitCode(decision)
|
||||
}
|
||||
|
||||
func finalizeDecision(block bool, waiverDiags []report.Diagnostic, decision semantic.Decision) semantic.Decision {
|
||||
decision.BlockMode = block
|
||||
if !block && len(decision.Blockers) > 0 {
|
||||
for i := range decision.Blockers {
|
||||
@@ -81,15 +98,21 @@ func run(args []string) int {
|
||||
decision.Blockers = nil
|
||||
}
|
||||
decision.SystemWarnings = append(diagnosticSystemWarnings(waiverDiags), decision.SystemWarnings...)
|
||||
return decision
|
||||
}
|
||||
|
||||
func writeSemanticOutputs(decisionOut, markdownOut string, decision semantic.Decision) error {
|
||||
if err := semantic.WriteDecision(decisionOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write decision: %v\n", err)
|
||||
return 2
|
||||
return fmt.Errorf("write decision: %w", err)
|
||||
}
|
||||
if err := semantic.WriteMarkdown(markdownOut, decision); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "semantic-review: write markdown: %v\n", err)
|
||||
return 2
|
||||
return fmt.Errorf("write markdown: %w", err)
|
||||
}
|
||||
if block && len(decision.Blockers) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func decisionExitCode(decision semantic.Decision) int {
|
||||
if decision.BlockMode && len(decision.Blockers) > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
@@ -211,7 +212,19 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Skills: []facts.SkillFact{{
|
||||
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||
Line: 30,
|
||||
Changed: true,
|
||||
ReferencesInvalidCommand: true,
|
||||
}},
|
||||
}
|
||||
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
@@ -228,6 +241,71 @@ func TestRunWritesSkippedDecisionForUnavailableReviewer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortCircuitsEmptySemanticInputWithoutReviewer(t *testing.T) {
|
||||
t.Setenv("ARK_API_KEY", "")
|
||||
t.Setenv("ARK_BASE_URL", "")
|
||||
t.Setenv("ARK_MODEL", "")
|
||||
|
||||
repo := t.TempDir()
|
||||
writeSemanticConfig(t, repo, `{
|
||||
"schema_version": 1,
|
||||
"default_enforcement": "observe",
|
||||
"block_categories": ["skill_quality"]
|
||||
}`, `{
|
||||
"allowed": ["semantic-review-v1"],
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Commands: []facts.CommandFact{{
|
||||
Path: "service command 1",
|
||||
Domain: "service",
|
||||
Changed: true,
|
||||
Source: "service",
|
||||
}},
|
||||
Outputs: []facts.OutputFact{{
|
||||
Command: "service command 1",
|
||||
Domain: "service",
|
||||
Changed: true,
|
||||
Source: "service",
|
||||
IsList: true,
|
||||
HasDefaultLimit: true,
|
||||
HasDecisionField: true,
|
||||
}},
|
||||
}
|
||||
if semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must not contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
markdownPath := filepath.Join(t.TempDir(), "semantic.md")
|
||||
code := run([]string{"--repo", repo, "--facts", factsPath, "--decision-out", decisionPath, "--markdown-out", markdownPath, "--block"})
|
||||
if code != 0 {
|
||||
t.Fatalf("run() = %d, want clean pass", code)
|
||||
}
|
||||
decision := readDecision(t, decisionPath)
|
||||
if decision.Skipped || decision.Degraded || decision.InfrastructureFailure || !decision.BlockMode {
|
||||
t.Fatalf("expected non-degraded pass decision: %#v", decision)
|
||||
}
|
||||
if len(decision.SystemWarnings) != 0 || len(decision.Warnings) != 0 || len(decision.Blockers) != 0 {
|
||||
t.Fatalf("empty semantic view should not produce findings: %#v", decision)
|
||||
}
|
||||
data, err := os.ReadFile(markdownPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read markdown: %v", err)
|
||||
}
|
||||
markdown := string(data)
|
||||
if !strings.Contains(markdown, "No semantic blockers.") {
|
||||
t.Fatalf("markdown missing pass summary: %s", markdown)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(markdown), "skipped") || strings.Contains(strings.ToLower(markdown), "degraded") {
|
||||
t.Fatalf("markdown should not report semantic review as skipped/degraded: %s", markdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testing.T) {
|
||||
t.Setenv("ARK_API_KEY", "test-key")
|
||||
t.Setenv("ARK_BASE_URL", "")
|
||||
@@ -243,7 +321,19 @@ func TestRunWritesInfrastructureFailureDecisionForInvalidReviewerConfig(t *testi
|
||||
"allowed_base_urls": ["https://ark.ap-southeast.bytepluses.com/api/v3"]
|
||||
}`, "")
|
||||
factsPath := filepath.Join(t.TempDir(), "facts.json")
|
||||
if err := (facts.Facts{SchemaVersion: 1}).WriteFile(factsPath); err != nil {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
Skills: []facts.SkillFact{{
|
||||
SourceFile: "skills/lark-wiki/SKILL.md",
|
||||
Line: 30,
|
||||
Changed: true,
|
||||
ReferencesInvalidCommand: true,
|
||||
}},
|
||||
}
|
||||
if !semantic.BuildInputView(f).HasReviewableFacts() {
|
||||
t.Fatal("test setup must contain reviewable facts")
|
||||
}
|
||||
if err := f.WriteFile(factsPath); err != nil {
|
||||
t.Fatalf("write facts: %v", err)
|
||||
}
|
||||
decisionPath := filepath.Join(t.TempDir(), "decision.json")
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"error_hint",
|
||||
"default_output",
|
||||
"naming",
|
||||
"skill_quality"
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
],
|
||||
"rollout_groups": [
|
||||
{
|
||||
@@ -16,7 +17,8 @@
|
||||
},
|
||||
"categories": [
|
||||
"error_hint",
|
||||
"skill_quality"
|
||||
"skill_quality",
|
||||
"public_content_leakage"
|
||||
],
|
||||
"owner": "cli-owner",
|
||||
"reason": "first semantic blocking rollout only affects changed facts"
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
)
|
||||
|
||||
type Facts struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Commands []CommandFact `json:"commands,omitempty"`
|
||||
Skills []SkillFact `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorFact `json:"errors,omitempty"`
|
||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||
Examples []CommandExample `json:"examples,omitempty"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Commands []CommandFact `json:"commands,omitempty"`
|
||||
Skills []SkillFact `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityFact `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorFact `json:"errors,omitempty"`
|
||||
Outputs []OutputFact `json:"outputs,omitempty"`
|
||||
Examples []CommandExample `json:"examples,omitempty"`
|
||||
PublicContent []PublicContentFact `json:"public_content,omitempty"`
|
||||
Diagnostics []DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type CommandFact struct {
|
||||
@@ -109,6 +110,17 @@ type OutputFact struct {
|
||||
HasDecisionField bool `json:"has_decision_field,omitempty"`
|
||||
}
|
||||
|
||||
type PublicContentFact struct {
|
||||
Rule string `json:"rule"`
|
||||
Action report.Action `json:"action"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Excerpt string `json:"excerpt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
type DryRunRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
@@ -206,6 +218,11 @@ func BuildWithCommandLookup(m manifest.Manifest, commandLookup manifest.Manifest
|
||||
}
|
||||
}
|
||||
|
||||
func WithPublicContent(f Facts, publicContent []PublicContentFact) Facts {
|
||||
f.PublicContent = publicContent
|
||||
return f
|
||||
}
|
||||
|
||||
type commandScope struct {
|
||||
Domain string
|
||||
Source string
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
||||
Errors: []ErrorFact{{Code: "invalid_input", Message: "bad path", Hint: "pass --file", Retryable: false, HintActionCount: 1, RequiredHint: true}},
|
||||
Outputs: []OutputFact{{Command: "im messages list", Fields: []string{"message_id", "sender", "create_time"}, IsList: true, HasDefaultLimit: true, HasDecisionField: true}},
|
||||
Skills: []SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 1, DestructiveWithoutGuard: true, ScopeConflict: true}},
|
||||
PublicContent: []PublicContentFact{{Rule: "public_content_generic_credential", Action: report.ActionReject, File: "docs/public.md", Line: 4, Excerpt: "api_key = <redacted>"}},
|
||||
}
|
||||
data, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
@@ -43,7 +44,10 @@ func TestFactsSchemaCarriesGatekeeperFields(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal facts: %v", err)
|
||||
}
|
||||
if !got.Errors[0].RequiredHint || got.Outputs[0].Fields[0] != "message_id" || !got.Skills[0].ScopeConflict {
|
||||
if !got.Errors[0].RequiredHint ||
|
||||
got.Outputs[0].Fields[0] != "message_id" ||
|
||||
!got.Skills[0].ScopeConflict ||
|
||||
got.PublicContent[0].Rule != "public_content_generic_credential" {
|
||||
t.Fatalf("facts lost gatekeeper fields: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
343
internal/qualitygate/publiccontent/collect.go
Normal file
343
internal/qualitygate/publiccontent/collect.go
Normal file
@@ -0,0 +1,343 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Collect(ctx context.Context, opts Options) ([]Finding, error) {
|
||||
metadata, err := LoadMetadata(opts.MetadataPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Finding
|
||||
changedFiles, base, err := changedFiles(ctx, opts.Repo, opts.ChangedFrom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches := map[string][]changedChunk{}
|
||||
if base != "" {
|
||||
patches, err = changedPatches(ctx, opts.Repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, file := range changedFiles {
|
||||
if !scanChangedFile(file) {
|
||||
continue
|
||||
}
|
||||
for _, chunk := range patches[file] {
|
||||
findings := scanText(file, "file", chunk.Text, isDetectorRuleFile(file))
|
||||
for i := range findings {
|
||||
findings[i].Line += chunk.StartLine - 1
|
||||
}
|
||||
out = append(out, findings...)
|
||||
out = append(out, semanticCandidate(file, "file", chunk.Text, chunk.StartLine)...)
|
||||
}
|
||||
privateKeyFindings, err := scanTouchedPrivateKeyBlocks(ctx, opts.Repo, file, patches[file])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = appendUniqueFindings(out, privateKeyFindings...)
|
||||
}
|
||||
if base != "" {
|
||||
commitFindings, err := scanCommitMessages(ctx, opts.Repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, commitFindings...)
|
||||
}
|
||||
branchName := opts.BranchName
|
||||
if branchName == "" {
|
||||
branchName = metadata.Branch
|
||||
}
|
||||
if branchName == "" {
|
||||
branchName = branchFromEnv()
|
||||
}
|
||||
if branchName == "" {
|
||||
branchName = currentBranch(ctx, opts.Repo)
|
||||
}
|
||||
if branchName != "" {
|
||||
out = append(out, scanText("branch", "branch", branchName, false)...)
|
||||
}
|
||||
out = append(out, scanMetadata(metadata)...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func currentBranch(ctx context.Context, repo string) string {
|
||||
data, err := gitOutput(ctx, repo, "branch", "--show-current")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func branchFromEnv() string {
|
||||
for _, key := range []string{"PR_BRANCH", "GITHUB_HEAD_REF", "GITHUB_REF_NAME"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func changedFiles(ctx context.Context, repo, changedFrom string) ([]string, string, error) {
|
||||
if changedFrom == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
baseBytes, err := gitOutput(ctx, repo, "merge-base", changedFrom, "HEAD")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
base := strings.TrimSpace(string(baseBytes))
|
||||
files, err := diffFileNames(ctx, repo, base)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sort.Strings(files)
|
||||
return files, base, nil
|
||||
}
|
||||
|
||||
func diffFileNames(ctx context.Context, repo, base string) ([]string, error) {
|
||||
data, err := gitOutput(ctx, repo, "diff", "--name-only", "-z", "--diff-filter=ACMR", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
for _, file := range bytes.Split(data, []byte{0}) {
|
||||
if len(file) == 0 {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.ToSlash(string(file)))
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
var detectorFixtureExclusions = map[string]bool{
|
||||
"internal/qualitygate/publiccontent/collect_test.go": true,
|
||||
"internal/qualitygate/publiccontent/rules.go": true,
|
||||
"internal/qualitygate/publiccontent/scan.go": true,
|
||||
"internal/qualitygate/publiccontent/scan_test.go": true,
|
||||
}
|
||||
|
||||
func scanChangedFile(file string) bool {
|
||||
normalized := strings.TrimPrefix(strings.ReplaceAll(file, "\\", "/"), "./")
|
||||
return !detectorFixtureExclusions[normalized]
|
||||
}
|
||||
|
||||
type changedChunk struct {
|
||||
StartLine int
|
||||
Text string
|
||||
}
|
||||
|
||||
func (c changedChunk) endLine() int {
|
||||
lines := strings.Count(strings.TrimRight(c.Text, "\n"), "\n") + 1
|
||||
if lines < 1 {
|
||||
lines = 1
|
||||
}
|
||||
return c.StartLine + lines - 1
|
||||
}
|
||||
|
||||
func changedPatches(ctx context.Context, repo, base string) (map[string][]changedChunk, error) {
|
||||
files, err := diffFileNames(ctx, repo, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := gitOutput(ctx, repo, "diff", "--no-ext-diff", "--unified=0", "--diff-filter=ACMR", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]changedChunk{}
|
||||
var file string
|
||||
var chunk *changedChunk
|
||||
nextLine := 0
|
||||
nextFile := 0
|
||||
flush := func() {
|
||||
if file == "" || chunk == nil || chunk.Text == "" {
|
||||
chunk = nil
|
||||
return
|
||||
}
|
||||
out[file] = append(out[file], *chunk)
|
||||
chunk = nil
|
||||
}
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(raw, "diff --git "):
|
||||
flush()
|
||||
file = ""
|
||||
if nextFile < len(files) {
|
||||
file = files[nextFile]
|
||||
nextFile++
|
||||
}
|
||||
case strings.HasPrefix(raw, "@@ "):
|
||||
flush()
|
||||
start, ok := parseNewHunkStart(raw)
|
||||
if !ok {
|
||||
nextLine = 0
|
||||
continue
|
||||
}
|
||||
nextLine = start
|
||||
chunk = &changedChunk{StartLine: start}
|
||||
case strings.HasPrefix(raw, "+") && !strings.HasPrefix(raw, "+++"):
|
||||
if chunk == nil {
|
||||
chunk = &changedChunk{StartLine: max(nextLine, 1)}
|
||||
}
|
||||
chunk.Text += strings.TrimPrefix(raw, "+") + "\n"
|
||||
nextLine++
|
||||
case strings.HasPrefix(raw, "-"):
|
||||
continue
|
||||
default:
|
||||
if chunk != nil && strings.HasPrefix(raw, `\ No newline at end of file`) {
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseNewHunkStart(header string) (int, bool) {
|
||||
parts := strings.Split(header, " ")
|
||||
for _, part := range parts {
|
||||
if !strings.HasPrefix(part, "+") {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimPrefix(part, "+")
|
||||
if before, _, ok := strings.Cut(raw, ","); ok {
|
||||
raw = before
|
||||
}
|
||||
start, err := strconv.Atoi(raw)
|
||||
return start, err == nil && start > 0
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func scanCommitMessages(ctx context.Context, repo, base string) ([]Finding, error) {
|
||||
data, err := gitOutput(ctx, repo, "log", "--format=%H%x00%B%x00", base+"..HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := bytes.Split(data, []byte{0})
|
||||
var out []Finding
|
||||
for i := 0; i+1 < len(parts); i += 2 {
|
||||
sha := strings.TrimSpace(string(parts[i]))
|
||||
body := string(parts[i+1])
|
||||
if sha == "" || body == "" {
|
||||
continue
|
||||
}
|
||||
short := sha
|
||||
if len(short) > 12 {
|
||||
short = short[:12]
|
||||
}
|
||||
out = append(out, scanText("commit:"+short, "commit", body, false)...)
|
||||
out = append(out, semanticCandidate("commit:"+short, "commit", body, 1)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type lineRange struct {
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
func scanTouchedPrivateKeyBlocks(ctx context.Context, repo, file string, chunks []changedChunk) ([]Finding, error) {
|
||||
if len(chunks) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := gitOutput(ctx, repo, "show", "HEAD:"+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var added []lineRange
|
||||
for _, chunk := range chunks {
|
||||
added = append(added, lineRange{Start: chunk.StartLine, End: chunk.endLine()})
|
||||
}
|
||||
var out []Finding
|
||||
for _, block := range privateKeyBlocks(string(data)) {
|
||||
if !rangesIntersectAny(block, added) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_private_key_block", file, block.Start, "file", "private key block"))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func privateKeyBlocks(text string) []lineRange {
|
||||
lines := strings.Split(text, "\n")
|
||||
var out []lineRange
|
||||
inPrivateKey := false
|
||||
start := 0
|
||||
for i, line := range lines {
|
||||
lineNo := i + 1
|
||||
if !inPrivateKey && strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = true
|
||||
start = lineNo
|
||||
}
|
||||
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, lineRange{Start: start, End: lineNo})
|
||||
inPrivateKey = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rangesIntersectAny(block lineRange, ranges []lineRange) bool {
|
||||
for _, r := range ranges {
|
||||
if block.Start <= r.End && r.Start <= block.End {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func appendUniqueFindings(items []Finding, additions ...Finding) []Finding {
|
||||
for _, addition := range additions {
|
||||
duplicate := false
|
||||
for _, item := range items {
|
||||
if item.Rule == addition.Rule &&
|
||||
item.File == addition.File &&
|
||||
item.Line == addition.Line &&
|
||||
item.Source == addition.Source {
|
||||
duplicate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !duplicate {
|
||||
items = append(items, addition)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func gitOutput(ctx context.Context, repo string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", args...)
|
||||
cmd.Dir = repo
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, stderr.Bytes())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
885
internal/qualitygate/publiccontent/collect_test.go
Normal file
@@ -0,0 +1,885 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "baseline.md"), `BASE_`+`TOKEN="baseline-only"
|
||||
`)
|
||||
runGit(t, repo, "add", "baseline.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
|
||||
|
||||
api_`+`key = "example-public-key"
|
||||
`)
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"title":"publish public docs","body":"Reviewed`+`-on: https://review.example.test/c/project/+/123"}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
|
||||
rules := findingRules(got)
|
||||
for _, want := range []string{
|
||||
"public_content_generic_credential",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_reviewed_on_trailer",
|
||||
} {
|
||||
if !rules[want] {
|
||||
t.Fatalf("missing rule %s in findings %#v", want, got)
|
||||
}
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.File == "baseline.md" {
|
||||
t.Fatalf("collector scanned unchanged baseline file: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScansOnlyChangedLinesInChangedFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\n")
|
||||
runGit(t, repo, "add", "docs/workflow.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "workflow.md"), "SECRET_TOKEN=legacy-example\npublic baseline\nnew public line\n")
|
||||
runGit(t, repo, "add", "docs/workflow.md")
|
||||
runGit(t, repo, "commit", "-m", "add public line")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_generic_credential" && item.File == "docs/workflow.md" {
|
||||
t.Fatalf("collector scanned unchanged legacy line in changed file: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSemanticCandidatesStoreSanitizedReviewText(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
raw := "private launch plan for alpha-service rollout on Friday with SERVICE_" + "TOKEN=real-" + "secret-value"
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, item := range got {
|
||||
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !strings.Contains(item.Excerpt, "alpha-service rollout on Friday") {
|
||||
t.Fatalf("semantic candidate should include sanitized review text, got %#v", item)
|
||||
}
|
||||
if strings.Contains(item.Excerpt, "real-"+"secret-value") {
|
||||
t.Fatalf("semantic candidate leaked credential value: %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||
t.Fatalf("semantic candidate should redact credentials in review text, got %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "semantic signals") || !strings.Contains(item.Excerpt, "roadmap_timing") {
|
||||
t.Fatalf("semantic candidate excerpt should preserve semantic signals, got %#v", item)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSemanticCandidatesDoNotLeakWhitespaceCredentialTail(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
raw := "private launch plan for internal rollout on Friday with SERVICE_" + "TOKEN=\"real " + "secret value\""
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add semantic candidate")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.Rule != "public_content_semantic_candidate" || item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item.Excerpt, "secret value") || strings.Contains(item.Excerpt, "real "+"secret value") {
|
||||
t.Fatalf("semantic candidate leaked credential tail: %#v", item)
|
||||
}
|
||||
if !strings.Contains(item.Excerpt, "SERVICE_TOKEN=<redacted>") {
|
||||
t.Fatalf("semantic candidate should redact full credential assignment, got %#v", item)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("missing semantic candidate in findings %#v", got)
|
||||
}
|
||||
|
||||
func TestCollectJSONBearerHeadersDoNotLeakIntoSemanticCandidates(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
token := "abcdefghijklmnopqrstuvwxyz"
|
||||
raw := "private launch plan for internal rollout on Friday with " +
|
||||
`{"headers":{"Authorization":"Bearer ` + token + `"}}`
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "base\n"+raw+"\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add json bearer")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/public.md", "public_content_bearer_header")
|
||||
for _, item := range got {
|
||||
if item.File != "docs/public.md" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(item.Excerpt, token) {
|
||||
t.Fatalf("finding leaked JSON bearer token: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_` + `token":"real-json-token"}`,
|
||||
`{"client_` + `secret": "real ` + `secret value"}`,
|
||||
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
|
||||
`{"github` + `Token":"real-github-token"}`,
|
||||
`{"vendorApi` + `Key":"real-vendor-key"}`,
|
||||
`{"slackBot` + `Token":"xoxb-real-token"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add json config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-json-token",
|
||||
"real secret value",
|
||||
"real-tenant-camel-token",
|
||||
"real-github-token",
|
||||
"real-vendor-key",
|
||||
"xoxb-real-token",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 6 {
|
||||
t.Fatalf("JSON credential findings = %d, want 6: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignJSONTokenFields(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"tokenizer":"cl100k_base"}`,
|
||||
`{"token_count": 42}`,
|
||||
`{"page_token":"next"}`,
|
||||
`{"next_page_token":"next"}`,
|
||||
`{"file_token":"file-example"}`,
|
||||
`{"doc_token":"doc-example"}`,
|
||||
`{"node_token":"node-example"}`,
|
||||
`{"wiki_token":"wikcn_public_doc_example"}`,
|
||||
`{"folder_token":"folder-example"}`,
|
||||
`{"obj_token":"obj-example"}`,
|
||||
`{"spreadsheet_token":"sheet-example"}`,
|
||||
`{"parent_node_token":"parent-example"}`,
|
||||
`{"origin_node_token":"origin-example"}`,
|
||||
`{"drive_route_token":"route-example"}`,
|
||||
`{"token":"<wiki_token>"}`,
|
||||
`{"token":"wiki_token"}`,
|
||||
`{"token_url":"https://example.com/oauth/token"}`,
|
||||
`{"token_endpoint":"https://example.com/oauth/token"}`,
|
||||
`{"token_format":"Bearer"}`,
|
||||
`{"secret_name":"public-example-secret"}`,
|
||||
`{"base_token":"base-example"}`,
|
||||
`{"app_token":"app-example"}`,
|
||||
`{"sync_token":"sync-example"}`,
|
||||
`{"parent_token":"parent-example"}`,
|
||||
`{"target_token":"target-example"}`,
|
||||
`{"parent_file_token":"parent-file-example"}`,
|
||||
`{"refresh_token_expires_in": 7200}`,
|
||||
`{"access_token_expires_in": 7200}`,
|
||||
`{"token_expires_in": 7200}`,
|
||||
`{"token_status":"active"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add benign json token fields")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("benign JSON token field should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY: <" + stripeLike + ">",
|
||||
"SECRET_TOKEN: <" + patLike + ">",
|
||||
"CLIENT_SECRET: <real-client-secret-value>",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), "{}\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
stripeLike := "sk_" + "live_1234567890abcdef"
|
||||
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
|
||||
`{"access_token_expires_in":"` + patLike + `"}`,
|
||||
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
|
||||
`{"client_secret_status":"real-client-secret-value"}`,
|
||||
`{"client_secret_name":"real-client-secret-value"}`,
|
||||
`{"app_token":"` + patLike + `"}`,
|
||||
`{"sync_token":"` + stripeLike + `"}`,
|
||||
`{"target_token":"real-client-secret-value"}`,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/public.json")
|
||||
runGit(t, repo, "commit", "-m", "add credential-shaped benign fields")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 7 {
|
||||
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_NAME: prod_key",
|
||||
"CLIENT_SECRET_NAME: prod_secret",
|
||||
"SECRET_STATUS: prod_secret",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"AWS_ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY_ID: " + accessKey,
|
||||
"ACCESS_KEY: " + accessKey,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add access key config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
|
||||
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("access key credential findings = %d, want 3: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
privateKey := "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0t"
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"PRIVATE_KEY: " + privateKey,
|
||||
"SSH_PRIVATE_KEY: " + privateKey,
|
||||
"JWT_PRIVATE_KEY: " + privateKey,
|
||||
"SIGNING_PRIVATE_KEY: " + privateKey,
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add private key config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.Contains(item.Excerpt, privateKey) {
|
||||
t.Fatalf("private key finding leaked value in excerpt %q", item.Excerpt)
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("private key assignment findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: prod_key",
|
||||
"CLIENT_SECRET_GOOGLE: prod_secret",
|
||||
"TOKEN_GITHUB: github_token",
|
||||
"APP_PASSWORD_PROD: prod_password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 4 {
|
||||
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"tokens: 128",
|
||||
"token_type: bearer",
|
||||
"max_tokens: 2000",
|
||||
"completion_tokens: 200",
|
||||
"prompt_tokens: 100",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add benign token config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
|
||||
t.Fatalf("benign unquoted token field should not be credential finding: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
|
||||
"API_KEY_OPENAI: real-openai-key",
|
||||
"TOKEN_GITHUB: real-github-token",
|
||||
"CLIENT_SECRET_GOOGLE: real-google-secret",
|
||||
"SECRET_KEY_BASE: real-secret-key-base",
|
||||
"APP_PASSWORD_PROD: real-prod-password",
|
||||
}, "\n")+"\n")
|
||||
runGit(t, repo, "add", "docs/config.yaml")
|
||||
runGit(t, repo, "commit", "-m", "add credential config")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
var count int
|
||||
for _, item := range got {
|
||||
if item.File != "docs/config.yaml" || item.Rule != "public_content_generic_credential" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
for _, forbidden := range []string{
|
||||
"real-openai-key",
|
||||
"real-github-token",
|
||||
"real-google-secret",
|
||||
"real-secret-key-base",
|
||||
"real-prod-password",
|
||||
} {
|
||||
if strings.Contains(item.Excerpt, forbidden) {
|
||||
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count != 5 {
|
||||
t.Fatalf("credential suffix variants findings = %d, want 5: %#v", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyEndIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n")
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\nnew-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "complete key")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyBeginIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), "legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "complete key")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectDetectsPrivateKeyWhenOnlyBodyIsAdded(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"new-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "add body")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/key.pem", "public_content_private_key_block")
|
||||
}
|
||||
|
||||
func TestCollectIgnoresUntouchedHistoricalPrivateKey(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
writeFile(t, filepath.Join(repo, "docs", "public.md"), "public docs update\n")
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "docs update")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||
t.Fatalf("collector reported untouched historical private key: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectIgnoresDeletedPrivateKeyLine(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+"legacy-body\n"+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "key.pem"), privateKeyBegin()+privateKeyEnd())
|
||||
runGit(t, repo, "add", "docs/key.pem")
|
||||
runGit(t, repo, "commit", "-m", "remove body")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.File == "docs/key.pem" && item.Rule == "public_content_private_key_block" {
|
||||
t.Fatalf("collector reported delete-only private key cleanup: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "collect_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
|
||||
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
|
||||
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add scanner fixtures")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
var foundOrdinaryTestLeak bool
|
||||
for _, item := range got {
|
||||
switch item.File {
|
||||
case "internal/qualitygate/publiccontent/collect_test.go",
|
||||
"internal/qualitygate/publiccontent/scan.go",
|
||||
"internal/qualitygate/publiccontent/scan_test.go",
|
||||
"internal/qualitygate/publiccontent/rules.go":
|
||||
t.Fatalf("collector scanned known fixture or detector implementation file: %#v", got)
|
||||
}
|
||||
if item.File == "tests/e2e/new-public-workflow.test.sh" && item.Rule == "public_content_generic_credential" {
|
||||
foundOrdinaryTestLeak = true
|
||||
}
|
||||
}
|
||||
if !foundOrdinaryTestLeak {
|
||||
t.Fatalf("collector should still scan ordinary test files for real leaks: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanChangedFileDocumentsFixtureExclusions(t *testing.T) {
|
||||
excluded := []string{
|
||||
"internal/qualitygate/publiccontent/collect_test.go",
|
||||
"internal/qualitygate/publiccontent/rules.go",
|
||||
"internal/qualitygate/publiccontent/scan.go",
|
||||
"internal/qualitygate/publiccontent/scan_test.go",
|
||||
}
|
||||
for _, file := range excluded {
|
||||
if scanChangedFile(file) {
|
||||
t.Fatalf("scanChangedFile(%q) = true, want false for detector fixture/implementation path", file)
|
||||
}
|
||||
}
|
||||
|
||||
included := []string{
|
||||
"internal/qualitygate/publiccontent/new_test.go",
|
||||
"tests/e2e/new-public-workflow.test.sh",
|
||||
"docs/public.md",
|
||||
}
|
||||
for _, file := range included {
|
||||
if !scanChangedFile(file) {
|
||||
t.Fatalf("scanChangedFile(%q) = false, want true", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "old.md"), "base\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
|
||||
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
|
||||
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
|
||||
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
|
||||
runGit(t, repo, "add", ".")
|
||||
runGit(t, repo, "commit", "-m", "add special paths")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
requireFinding(t, got, "docs/has space.md", "public_content_generic_credential")
|
||||
requireFinding(t, got, `weird"quote.md`, "public_content_generic_credential")
|
||||
requireFinding(t, got, "docs/new name.md", "public_content_generic_credential")
|
||||
}
|
||||
|
||||
func TestCollectScansBranchNameAsWarning(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"branch":"bot/public-doc-update"}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Rule != "public_content_automation_branch" {
|
||||
t.Fatalf("branch findings = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectUsesExplicitBranchNameWhenDetached(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
runGit(t, repo, "checkout", "-b", "bot/public-doc-update")
|
||||
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||
runGit(t, repo, "add", "docs.md")
|
||||
runGit(t, repo, "commit", "-m", "docs")
|
||||
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||
runGit(t, repo, "checkout", "--detach", head)
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
BranchName: "bot/public-doc-update",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||
}
|
||||
|
||||
func TestCollectUsesBranchEnvironmentWhenDetached(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "README.md"), "base\n")
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
runGit(t, repo, "checkout", "-b", "bot/public-env-update")
|
||||
writeFile(t, filepath.Join(repo, "docs.md"), "safe docs\n")
|
||||
runGit(t, repo, "add", "docs.md")
|
||||
runGit(t, repo, "commit", "-m", "docs")
|
||||
head := strings.TrimSpace(string(runGitOutput(t, repo, "rev-parse", "HEAD")))
|
||||
runGit(t, repo, "checkout", "--detach", head)
|
||||
t.Setenv("GITHUB_HEAD_REF", "bot/public-env-update")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
requireFinding(t, got, "branch", "public_content_automation_branch")
|
||||
}
|
||||
|
||||
func TestCollectPreservesFindingAttributionForChangedLines(t *testing.T) {
|
||||
repo := newGitRepo(t)
|
||||
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\n")
|
||||
runGit(t, repo, "add", "docs/auth.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
writeFile(t, filepath.Join(repo, "docs", "auth.md"), "intro\nAuthorization: Bearer abcdefghijklmnopqrstuvwxyz\n")
|
||||
runGit(t, repo, "add", "docs/auth.md")
|
||||
runGit(t, repo, "commit", "-m", "add auth docs")
|
||||
|
||||
got := collectFromPreviousCommit(t, repo)
|
||||
for _, item := range got {
|
||||
if item.Rule == "public_content_bearer_header" {
|
||||
if item.File != "docs/auth.md" || item.Line != 2 || item.Source != "file" {
|
||||
t.Fatalf("changed-line attribution = %#v", item)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing bearer finding: %#v", got)
|
||||
}
|
||||
|
||||
func TestAppendUniqueFindingsDeduplicatesByRuleFileLineAndSource(t *testing.T) {
|
||||
base := []Finding{newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block")}
|
||||
got := appendUniqueFindings(base,
|
||||
newFinding("public_content_private_key_block", "docs/key.pem", 1, "file", "private key block"),
|
||||
newFinding("public_content_private_key_block", "docs/key.pem", 2, "file", "private key block"),
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("appendUniqueFindings len = %d, want 2: %#v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func newGitRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
return repo
|
||||
}
|
||||
|
||||
func privateKeyBegin() string {
|
||||
return privateKeyBeginPrefix + privateKeyMarker + "\n"
|
||||
}
|
||||
|
||||
func privateKeyEnd() string {
|
||||
return privateKeyEndPrefix + privateKeyMarker + "\n"
|
||||
}
|
||||
|
||||
func collectFromPreviousCommit(t *testing.T, repo string) []Finding {
|
||||
t.Helper()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{}`)
|
||||
got, err := Collect(context.Background(), Options{
|
||||
Repo: repo,
|
||||
ChangedFrom: "HEAD~1",
|
||||
MetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect() error = %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func requireFinding(t *testing.T, got []Finding, file, rule string) {
|
||||
t.Helper()
|
||||
for _, item := range got {
|
||||
if item.File == file && item.Rule == rule {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %s in %s findings: %#v", rule, file, got)
|
||||
}
|
||||
|
||||
func TestCollectRequiresValidMetadataJSON(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
writeFile(t, metadataPath, `{"title":`)
|
||||
|
||||
_, err := Collect(context.Background(), Options{Repo: repo, MetadataPath: metadataPath})
|
||||
if err == nil || !strings.Contains(err.Error(), "public content metadata") {
|
||||
t.Fatalf("Collect() error = %v, want metadata parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
if len(args) > 0 && args[0] == "commit" {
|
||||
args = append([]string{"commit", "--no-verify"}, args[1:]...)
|
||||
}
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = repo
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v failed: %v\n%s", args, err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
11
internal/qualitygate/publiccontent/comment_audit.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
func ScanComment(kind, body string) []Finding {
|
||||
if kind == "" {
|
||||
kind = "comment"
|
||||
}
|
||||
return scanText(kind, "comment", body, false)
|
||||
}
|
||||
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
19
internal/qualitygate/publiccontent/comment_audit_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
|
||||
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
|
||||
rules := findingRules(got)
|
||||
if !rules["public_content_harness_metadata"] || !rules["public_content_ccm_harness_trailer"] {
|
||||
t.Fatalf("comment audit findings = %#v", got)
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.File != "issue_comment" {
|
||||
t.Fatalf("comment finding file = %q, want issue_comment", item.File)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/qualitygate/publiccontent/metadata.go
Normal file
45
internal/qualitygate/publiccontent/metadata.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
func LoadMetadata(path string) (Metadata, error) {
|
||||
if path == "" {
|
||||
return Metadata{}, nil
|
||||
}
|
||||
data, err := vfs.ReadFile(path)
|
||||
if err != nil {
|
||||
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return Metadata{}, nil
|
||||
}
|
||||
var out Metadata
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return Metadata{}, fmt.Errorf("public content metadata: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanMetadata(m Metadata) []Finding {
|
||||
text := ""
|
||||
if m.Title != "" {
|
||||
text += "title: " + m.Title + "\n"
|
||||
}
|
||||
if m.Body != "" {
|
||||
text += "body:\n" + m.Body + "\n"
|
||||
}
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
out := scanText("pull_request_metadata", "metadata", text, false)
|
||||
out = append(out, semanticCandidate("pull_request_metadata", "metadata", text, 1)...)
|
||||
return out
|
||||
}
|
||||
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
22
internal/qualitygate/publiccontent/metadata_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMetadataReadsTitleAndBody(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "metadata.json")
|
||||
writeFile(t, path, `{"title":"public change","body":"pass`+`word = \"example-password\""}`)
|
||||
|
||||
got, err := LoadMetadata(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMetadata() error = %v", err)
|
||||
}
|
||||
if got.Title != "public change" || got.Body == "" {
|
||||
t.Fatalf("metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
441
internal/qualitygate/publiccontent/rules.go
Normal file
441
internal/qualitygate/publiccontent/rules.go
Normal file
@@ -0,0 +1,441 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
var (
|
||||
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
|
||||
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
|
||||
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
|
||||
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
|
||||
semanticBearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+[^"'\s,}\]]+|["']Authorization["']\s*:\s*["']Bearer\s+[^"'\\\s,}\]]+)`)
|
||||
changeIDTrailerRE = regexp.MustCompile(`(?i)^\s*Change-Id:\s*\S+`)
|
||||
reviewedOnTrailerRE = regexp.MustCompile(`(?i)^\s*Reviewed-on:\s*\S+`)
|
||||
ccmHarnessTrailerRE = regexp.MustCompile(`(?i)\bCCM-Harness:\s*\S+`)
|
||||
privateIPv4RE = regexp.MustCompile(`\b(?:10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|172\.(?:1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3})\b`)
|
||||
automationBranchRE = regexp.MustCompile(`(?i)(^|/)(bot|automation)[-/]`)
|
||||
)
|
||||
|
||||
func actionForRule(rule string) report.Action {
|
||||
switch rule {
|
||||
case "public_content_generic_credential",
|
||||
"public_content_private_key_block",
|
||||
"public_content_jwt_like_token",
|
||||
"public_content_bearer_header",
|
||||
"public_content_credential_url",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_reviewed_on_trailer",
|
||||
"public_content_provenance_marker",
|
||||
"public_content_detector_fingerprint",
|
||||
"public_content_harness_metadata",
|
||||
"public_content_ccm_harness_trailer":
|
||||
return report.ActionReject
|
||||
case "public_content_private_ipv4",
|
||||
"public_content_automation_branch":
|
||||
return report.ActionWarning
|
||||
default:
|
||||
return report.ActionWarning
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderValue(value string) bool {
|
||||
trimmed := strings.Trim(value, `"'`)
|
||||
normalized := strings.ToLower(trimmed)
|
||||
if normalized == "" ||
|
||||
normalized == "=" ||
|
||||
percentWrappedPlaceholder(normalized) ||
|
||||
angleWrappedPlaceholder(normalized) ||
|
||||
urlWithAnglePlaceholder(normalized) ||
|
||||
isCredentialReferenceValue(trimmed) {
|
||||
return true
|
||||
}
|
||||
return namedPlaceholderValue(normalized)
|
||||
}
|
||||
|
||||
func namedPlaceholderValue(value string) bool {
|
||||
switch value {
|
||||
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
|
||||
return true
|
||||
}
|
||||
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
|
||||
}
|
||||
|
||||
func allXPlaceholder(value string) bool {
|
||||
if len(value) < 4 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r != 'x' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func urlWithAnglePlaceholder(value string) bool {
|
||||
if !strings.Contains(value, "://") ||
|
||||
!strings.Contains(value, "<") ||
|
||||
!strings.Contains(value, ">") {
|
||||
return false
|
||||
}
|
||||
return !urlRemainderLooksCredentialLike(removeAnglePlaceholders(value))
|
||||
}
|
||||
|
||||
func removeAnglePlaceholders(value string) string {
|
||||
var out strings.Builder
|
||||
for len(value) > 0 {
|
||||
start := strings.Index(value, "<")
|
||||
if start < 0 {
|
||||
out.WriteString(value)
|
||||
break
|
||||
}
|
||||
out.WriteString(value[:start])
|
||||
end := strings.Index(value[start+1:], ">")
|
||||
if end < 0 {
|
||||
out.WriteString(value[start:])
|
||||
break
|
||||
}
|
||||
value = value[start+end+2:]
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func urlRemainderLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{
|
||||
"secret",
|
||||
"token",
|
||||
"password",
|
||||
"passwd",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"privatekey",
|
||||
"client_secret",
|
||||
"clientsecret",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, part := range strings.FieldsFunc(normalized, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||
}) {
|
||||
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func longCredentialSegment(value string) bool {
|
||||
if len(value) < 16 {
|
||||
return false
|
||||
}
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
hasLetter = true
|
||||
case r >= '0' && r <= '9':
|
||||
hasDigit = true
|
||||
case r == '_' || r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasLetter || hasDigit
|
||||
}
|
||||
|
||||
func isCredentialReferenceValue(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
switch {
|
||||
case strings.HasPrefix(normalized, "${{"):
|
||||
return githubExpressionReference(normalized)
|
||||
case strings.HasPrefix(normalized, "$("):
|
||||
return !commandSubstitutionLooksCredentialLike(normalized)
|
||||
case strings.HasPrefix(normalized, "process.env."):
|
||||
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "process.env."))
|
||||
case strings.HasPrefix(normalized, "${"):
|
||||
return credentialReferenceIdentifier(strings.TrimSuffix(strings.TrimPrefix(normalized, "${"), "}"))
|
||||
case strings.HasPrefix(value, "$"):
|
||||
return credentialReferenceIdentifier(strings.TrimPrefix(normalized, "$"))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func commandSubstitutionLooksCredentialLike(value string) bool {
|
||||
if !strings.HasPrefix(value, "$(") || !strings.HasSuffix(value, ")") {
|
||||
return false
|
||||
}
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(value, "$("), ")")
|
||||
for _, part := range strings.FieldsFunc(inner, func(r rune) bool {
|
||||
return !((r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-')
|
||||
}) {
|
||||
if credentialShapedIdentifier(part) || longCredentialSegment(part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func githubExpressionReference(value string) bool {
|
||||
if !strings.HasPrefix(value, "${{") || !strings.HasSuffix(value, "}}") {
|
||||
return false
|
||||
}
|
||||
expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||
switch {
|
||||
case strings.HasPrefix(expr, "secrets."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "secrets."))
|
||||
case strings.HasPrefix(expr, "env."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "env."))
|
||||
case strings.HasPrefix(expr, "vars."):
|
||||
return dottedReferenceIdentifier(strings.TrimPrefix(expr, "vars."))
|
||||
case expr == "github.token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func dottedReferenceIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, part := range strings.Split(value, ".") {
|
||||
if !referenceIdentifier(part) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialReferenceIdentifier(value string) bool {
|
||||
return referenceIdentifier(value) && !credentialShapedIdentifier(value)
|
||||
}
|
||||
|
||||
func referenceIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for i, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
case r == '_' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func angleWrappedPlaceholder(value string) bool {
|
||||
if len(value) < 3 || !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") {
|
||||
return false
|
||||
}
|
||||
return anglePlaceholderIdentifier(strings.Trim(value, "<>"))
|
||||
}
|
||||
|
||||
func percentWrappedPlaceholder(value string) bool {
|
||||
if len(value) < 3 || !strings.HasPrefix(value, "%") || !strings.HasSuffix(value, "%") {
|
||||
return false
|
||||
}
|
||||
inner := strings.Trim(value, "%")
|
||||
return delimitedPlaceholderIdentifier(inner) && !credentialShapedIdentifier(inner)
|
||||
}
|
||||
|
||||
func delimitedPlaceholderIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anglePlaceholderIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
if credentialShapedIdentifier(value) {
|
||||
return false
|
||||
}
|
||||
switch value {
|
||||
case "token",
|
||||
"id",
|
||||
"userid",
|
||||
"openid",
|
||||
"key",
|
||||
"secret",
|
||||
"password",
|
||||
"api-key",
|
||||
"user-id",
|
||||
"open-id",
|
||||
"client-secret",
|
||||
"access-token",
|
||||
"refresh-token",
|
||||
"auth-token",
|
||||
"bearer-token",
|
||||
"session-token",
|
||||
"service-token":
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{"_token", "_id", "_key", "_secret", "_password"} {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{"-token", "-id", "-key", "-secret", "-password"} {
|
||||
if strings.HasSuffix(value, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialShapedValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||
return credentialShapedIdentifier(normalized)
|
||||
}
|
||||
|
||||
func credentialShapedIdentifier(value string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(value, "sk_live_"),
|
||||
strings.HasPrefix(value, "sk_test_"),
|
||||
strings.HasPrefix(value, "ghp_"),
|
||||
strings.HasPrefix(value, "gho_"),
|
||||
strings.HasPrefix(value, "ghu_"),
|
||||
strings.HasPrefix(value, "github_pat_"),
|
||||
strings.HasPrefix(value, "xoxb_"),
|
||||
strings.HasPrefix(value, "xoxp_"),
|
||||
strings.HasPrefix(value, "xoxa_"):
|
||||
return true
|
||||
case strings.HasPrefix(value, "real-") &&
|
||||
(strings.Contains(value, "secret") ||
|
||||
strings.Contains(value, "token") ||
|
||||
strings.Contains(value, "key") ||
|
||||
strings.Contains(value, "password")):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resourceTokenPlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
switch normalized {
|
||||
case "wiki_token",
|
||||
"folder_token",
|
||||
"obj_token",
|
||||
"spreadsheet_token",
|
||||
"file_token",
|
||||
"doc_token",
|
||||
"node_token",
|
||||
"parent_node_token",
|
||||
"origin_node_token",
|
||||
"drive_route_token":
|
||||
return true
|
||||
default:
|
||||
return minuteTokenFixturePlaceholder(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func minuteTokenFixturePlaceholder(value string) bool {
|
||||
if value == "minute_no_meta" {
|
||||
return true
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(value, "minute_")
|
||||
if !ok || suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func provenanceMarker(line string) bool {
|
||||
normalized := strings.ToLower(line)
|
||||
markers := []string{
|
||||
"generat" + "ed by tool",
|
||||
"creat" + "ed by tool",
|
||||
"generat" + "ed by automation",
|
||||
"creat" + "ed by automation",
|
||||
"machine-" + "generated",
|
||||
"generated with automated",
|
||||
"generated with automation",
|
||||
"🤖 generated",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(normalized, "co-authored-by:") &&
|
||||
(strings.Contains(normalized, "<bot@") ||
|
||||
strings.Contains(normalized, " bot@") ||
|
||||
strings.Contains(normalized, "[bot]") ||
|
||||
strings.Contains(normalized, "automation") ||
|
||||
strings.Contains(normalized, "automated-code-assistant")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Detector fingerprint checks are intentionally scoped to public rule/config
|
||||
// files. They do not try to hide this package's implementation; they prevent
|
||||
// publishing reusable detector identifiers in external-facing rule bundles.
|
||||
func isDetectorRuleFile(path string) bool {
|
||||
normalized := filepath.ToSlash(path)
|
||||
base := filepath.Base(normalized)
|
||||
return base == ".gitleaks.toml" ||
|
||||
strings.Contains(normalized, "public-rules/") ||
|
||||
strings.Contains(normalized, "public_rules/")
|
||||
}
|
||||
|
||||
func detectorFingerprint(line string) bool {
|
||||
normalized := strings.ToLower(line)
|
||||
fingerprints := []string{
|
||||
strings.Join([]string{"public", "content", "leakage"}, "-"),
|
||||
strings.Join([]string{"public", "content", "detector"}, "-"),
|
||||
"publiccontent",
|
||||
}
|
||||
for _, fingerprint := range fingerprints {
|
||||
if strings.Contains(normalized, fingerprint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactCredentialURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.User == nil {
|
||||
return "<credential-url>"
|
||||
}
|
||||
u.User = url.UserPassword("<user>", "<redacted>")
|
||||
return u.String()
|
||||
}
|
||||
797
internal/qualitygate/publiccontent/scan.go
Normal file
797
internal/qualitygate/publiccontent/scan.go
Normal file
@@ -0,0 +1,797 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyBeginPrefix = "-----" + "BEGIN "
|
||||
privateKeyEndPrefix = "-----" + "END "
|
||||
privateKeyMarker = "PRIVATE " + "KEY-----"
|
||||
)
|
||||
|
||||
func ScanFile(path string, data []byte) []Finding {
|
||||
return scanText(filepath.ToSlash(path), "file", string(data), isDetectorRuleFile(path))
|
||||
}
|
||||
|
||||
func semanticCandidate(file, source, text string, line int) []Finding {
|
||||
excerpt := redactedSemanticExcerpt(text)
|
||||
if excerpt == "" {
|
||||
return nil
|
||||
}
|
||||
return []Finding{newFinding("public_content_semantic_candidate", file, line, source, excerpt)}
|
||||
}
|
||||
|
||||
func scanText(file, source, text string, detectorFile bool) []Finding {
|
||||
var out []Finding
|
||||
lines := strings.Split(text, "\n")
|
||||
inPrivateKey := false
|
||||
privateKeyLine := 0
|
||||
for i, line := range lines {
|
||||
lineNo := i + 1
|
||||
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = true
|
||||
privateKeyLine = lineNo
|
||||
}
|
||||
if inPrivateKey && strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
|
||||
inPrivateKey = false
|
||||
}
|
||||
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
|
||||
if !isCredentialAssignmentMatch(match[0]) {
|
||||
continue
|
||||
}
|
||||
value := credentialAssignmentValue(match)
|
||||
keyName, _ := normalizedCredentialAssignmentKey(match[0])
|
||||
if value == "" ||
|
||||
isNonSecretLiteralValue(value) ||
|
||||
isBenignCodeCredentialExpression(file, value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
isResourceTokenPlaceholderAssignment(keyName, value) {
|
||||
continue
|
||||
}
|
||||
if looksLikeEqualityComparison(value) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
|
||||
}
|
||||
for _, match := range jwtLikeRE.FindAllString(line, -1) {
|
||||
if isSchemaDottedIdentifier(line, match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
|
||||
}
|
||||
for range bearerHeaderRE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||
}
|
||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||
if isPlaceholderCredentialURL(match) {
|
||||
continue
|
||||
}
|
||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||
}
|
||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||
}
|
||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||
out = append(out, newFinding("public_content_automation_branch", file, lineNo, source, "automation branch marker"))
|
||||
}
|
||||
switch {
|
||||
case changeIDTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_change_id_trailer", file, lineNo, source, "Change-Id: <redacted>"))
|
||||
case reviewedOnTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_reviewed_on_trailer", file, lineNo, source, "Reviewed-on: <redacted>"))
|
||||
case ccmHarnessTrailerRE.MatchString(line):
|
||||
out = append(out, newFinding("public_content_ccm_harness_trailer", file, lineNo, source, "CCM-Harness: <redacted>"))
|
||||
}
|
||||
if provenanceMarker(line) {
|
||||
out = append(out, newFinding("public_content_provenance_marker", file, lineNo, source, "provenance marker"))
|
||||
}
|
||||
if strings.Contains(line, "/tmp/harness-agent") {
|
||||
out = append(out, newFinding("public_content_harness_metadata", file, lineNo, source, "/tmp/harness-agent"))
|
||||
}
|
||||
if detectorFile && detectorFingerprint(line) {
|
||||
out = append(out, newFinding("public_content_detector_fingerprint", file, lineNo, source, "public detector fingerprint"))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].File != out[j].File {
|
||||
return out[i].File < out[j].File
|
||||
}
|
||||
if out[i].Line != out[j].Line {
|
||||
return out[i].Line < out[j].Line
|
||||
}
|
||||
return out[i].Rule < out[j].Rule
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func isCredentialAssignmentMatch(match string) bool {
|
||||
name, value, ok := normalizedCredentialAssignment(match)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
|
||||
return true
|
||||
}
|
||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
return isExplicitCredentialKey(name)
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignmentKey(match string) (string, bool) {
|
||||
key, _, ok := normalizedCredentialAssignment(match)
|
||||
return key, ok
|
||||
}
|
||||
|
||||
func normalizedCredentialAssignment(match string) (string, string, bool) {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", "", false
|
||||
}
|
||||
submatches := credentialAssignmentRE.FindStringSubmatch(match)
|
||||
return normalizedCredentialKey(strings.Trim(key, `"'`)), credentialAssignmentValue(submatches), true
|
||||
}
|
||||
|
||||
func normalizedCredentialKey(key string) string {
|
||||
key = strings.TrimSpace(key)
|
||||
var out []rune
|
||||
var prev rune
|
||||
for i, r := range key {
|
||||
if r == '-' {
|
||||
r = '_'
|
||||
}
|
||||
if i > 0 && isCredentialKeyBoundary(prev, r) {
|
||||
out = append(out, '_')
|
||||
}
|
||||
out = append(out, unicode.ToLower(r))
|
||||
prev = r
|
||||
}
|
||||
key = string(out)
|
||||
key = strings.ReplaceAll(key, "-", "_")
|
||||
return key
|
||||
}
|
||||
|
||||
func isCredentialKeyBoundary(prev, current rune) bool {
|
||||
if prev == '_' || current == '_' {
|
||||
return false
|
||||
}
|
||||
return (unicode.IsLower(prev) || unicode.IsDigit(prev)) && unicode.IsUpper(current)
|
||||
}
|
||||
|
||||
func isBenignTokenField(key string) bool {
|
||||
if isTokenMetricField(key) ||
|
||||
isTokenMetadataField(key) ||
|
||||
isResourceTokenField(key) ||
|
||||
isPaginationOrSyncTokenField(key) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTokenMetricField(key string) bool {
|
||||
switch key {
|
||||
case "tokenizer",
|
||||
"token_count",
|
||||
"tokens",
|
||||
"max_tokens",
|
||||
"completion_tokens",
|
||||
"prompt_tokens":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTokenMetadataField(key string) bool {
|
||||
switch key {
|
||||
case "access_token_expires_in",
|
||||
"refresh_token_expires_in",
|
||||
"token_expires_in",
|
||||
"token_status",
|
||||
"token_type",
|
||||
"token_url",
|
||||
"token_endpoint",
|
||||
"token_format",
|
||||
"secret_name":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPaginationOrSyncTokenField(key string) bool {
|
||||
switch key {
|
||||
case "page_token",
|
||||
"next_page_token",
|
||||
"sync_token":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResourceTokenField(key string) bool {
|
||||
if !strings.HasSuffix(key, "_token") {
|
||||
return false
|
||||
}
|
||||
prefix := strings.TrimSuffix(key, "_token")
|
||||
switch prefix {
|
||||
case "app",
|
||||
"base",
|
||||
"board",
|
||||
"doc",
|
||||
"drive_route",
|
||||
"file",
|
||||
"folder",
|
||||
"host_node",
|
||||
"minute",
|
||||
"node",
|
||||
"obj",
|
||||
"origin_node",
|
||||
"parent",
|
||||
"parent_file",
|
||||
"parent_node",
|
||||
"share",
|
||||
"spreadsheet",
|
||||
"target",
|
||||
"wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResourceTokenPlaceholderAssignment(key, value string) bool {
|
||||
switch {
|
||||
case key == "client_token" && idempotencyTokenPlaceholderValue(value):
|
||||
return true
|
||||
case key == "retry_without_token" && numericStringPlaceholderValue(value):
|
||||
return true
|
||||
case tokenLikePlaceholderKey(key):
|
||||
return tokenLikePlaceholderValue(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderKey(key string) bool {
|
||||
return key == "token" ||
|
||||
strings.HasSuffix(key, "_token") ||
|
||||
strings.HasSuffix(key, "-token")
|
||||
}
|
||||
|
||||
func tokenLikePlaceholderValue(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||
return false
|
||||
}
|
||||
return resourceTokenPlaceholderValue(value) ||
|
||||
isPlaceholderValue(value) ||
|
||||
normalized == "token" ||
|
||||
strings.Contains(normalized, "...") ||
|
||||
strings.Contains(normalized, "xxx") ||
|
||||
strings.Contains(normalized, "_or_") ||
|
||||
strings.HasSuffix(normalized, "_token") ||
|
||||
strings.HasPrefix(normalized, ".")
|
||||
}
|
||||
|
||||
func idempotencyTokenPlaceholderValue(value string) bool {
|
||||
return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value)
|
||||
}
|
||||
|
||||
func uuidStringPlaceholderValue(value string) bool {
|
||||
normalized := strings.Trim(value, `"'`)
|
||||
parts := strings.Split(normalized, "-")
|
||||
if len(parts) != 5 {
|
||||
return false
|
||||
}
|
||||
for i, part := range parts {
|
||||
want := []int{8, 4, 4, 4, 12}[i]
|
||||
if len(part) != want {
|
||||
return false
|
||||
}
|
||||
for _, r := range part {
|
||||
if (r >= '0' && r <= '9') ||
|
||||
(r >= 'a' && r <= 'f') ||
|
||||
(r >= 'A' && r <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func numericStringPlaceholderValue(value string) bool {
|
||||
normalized := strings.Trim(value, `"'`)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range normalized {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isBenignCodeCredentialExpression(file, value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
|
||||
return true
|
||||
}
|
||||
if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) {
|
||||
return false
|
||||
}
|
||||
return codeReferenceExpression(normalized)
|
||||
}
|
||||
|
||||
func sourceCodeFile(file string) bool {
|
||||
switch filepath.Ext(file) {
|
||||
case ".go", ".py":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func quotedLiteral(value string) bool {
|
||||
normalized := strings.TrimSpace(value)
|
||||
return len(normalized) >= 2 &&
|
||||
((strings.HasPrefix(normalized, `"`) && strings.HasSuffix(normalized, `"`)) ||
|
||||
(strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`)))
|
||||
}
|
||||
|
||||
func codeReferenceExpression(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{".", "(", ")", "[", "]", "{"} {
|
||||
if strings.Contains(value, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return codeIdentifier(value) && !credentialNameFragment(value)
|
||||
}
|
||||
|
||||
func codeIdentifier(value string) bool {
|
||||
for i, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r == '_' && i > 0:
|
||||
case r >= '0' && r <= '9' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func credentialNameFragment(value string) bool {
|
||||
normalized := strings.ToLower(value)
|
||||
for _, marker := range []string{"secret", "token", "password", "passwd", "key"} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSchemaDottedIdentifier(line, match string) bool {
|
||||
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
|
||||
}
|
||||
|
||||
func isNonSecretLiteralValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
|
||||
case "true", "false", "null", "nil", "{", "[":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isWebhookCredentialKey(key string) bool {
|
||||
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
|
||||
}
|
||||
|
||||
func webhookAssignmentValueLooksCredentialLike(value string) bool {
|
||||
normalized := strings.ToLower(strings.Trim(value, `"'`))
|
||||
if normalized == "" || isPlaceholderValue(normalized) || isNonSecretLiteralValue(normalized) {
|
||||
return false
|
||||
}
|
||||
return urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)) ||
|
||||
credentialShapedIdentifier(strings.Trim(normalized, "$"))
|
||||
}
|
||||
|
||||
func isExplicitCredentialKey(key string) bool {
|
||||
compact := strings.ReplaceAll(key, "_", "")
|
||||
switch compact {
|
||||
case "token",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"clientsecret",
|
||||
"password",
|
||||
"passwd":
|
||||
return true
|
||||
}
|
||||
for _, phrase := range []string{
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"bottoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"clientsecret",
|
||||
"secretkey",
|
||||
} {
|
||||
if strings.Contains(compact, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parts := credentialKeyParts(key)
|
||||
for _, phrase := range [][2]string{
|
||||
{"access", "token"},
|
||||
{"refresh", "token"},
|
||||
{"auth", "token"},
|
||||
{"bearer", "token"},
|
||||
{"session", "token"},
|
||||
{"service", "token"},
|
||||
{"bot", "token"},
|
||||
{"api", "key"},
|
||||
{"access", "key"},
|
||||
{"private", "key"},
|
||||
{"api", "secret"},
|
||||
{"client", "secret"},
|
||||
{"secret", "key"},
|
||||
} {
|
||||
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, part := range parts {
|
||||
switch part {
|
||||
case "token", "secret", "password", "passwd":
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"token",
|
||||
"accesstoken",
|
||||
"refreshtoken",
|
||||
"authtoken",
|
||||
"bearertoken",
|
||||
"sessiontoken",
|
||||
"servicetoken",
|
||||
"bottoken",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"privatekey",
|
||||
"apisecret",
|
||||
"clientsecret",
|
||||
"secret",
|
||||
"secretkey",
|
||||
"password",
|
||||
"passwd",
|
||||
} {
|
||||
if strings.HasSuffix(compact, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, suffix := range []string{
|
||||
"_access_token",
|
||||
"_refresh_token",
|
||||
"_auth_token",
|
||||
"_bearer_token",
|
||||
"_session_token",
|
||||
"_service_token",
|
||||
"_api_key",
|
||||
"_access_key",
|
||||
"_private_key",
|
||||
"_api_secret",
|
||||
"_client_secret",
|
||||
"_secret",
|
||||
"_secret_key",
|
||||
"_password",
|
||||
"_passwd",
|
||||
} {
|
||||
if strings.HasSuffix(key, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialKeyParts(key string) []string {
|
||||
var parts []string
|
||||
for _, part := range strings.Split(key, "_") {
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func hasAdjacentCredentialParts(parts []string, first, second string) bool {
|
||||
for i := 0; i+1 < len(parts); i++ {
|
||||
if parts[i] == first && parts[i+1] == second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func credentialAssignmentValue(match []string) string {
|
||||
for _, value := range match[1:] {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func looksLikeEqualityComparison(value string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||
}
|
||||
|
||||
func isPlaceholderCredentialURL(raw string) bool {
|
||||
userInfo, ok := credentialURLUserInfo(raw)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, password, ok := strings.Cut(userInfo, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return credentialURLPasswordPlaceholder(password)
|
||||
}
|
||||
|
||||
func credentialURLPasswordPlaceholder(password string) bool {
|
||||
normalized := strings.ToLower(password)
|
||||
decoded := strings.ReplaceAll(normalized, "%3c", "<")
|
||||
decoded = strings.ReplaceAll(decoded, "%3e", ">")
|
||||
switch decoded {
|
||||
case "placeholder", "redacted", "<redacted>", "xxxx":
|
||||
return true
|
||||
}
|
||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||
}
|
||||
|
||||
func credentialURLUserInfo(raw string) (string, bool) {
|
||||
schemeIdx := strings.Index(raw, "://")
|
||||
if schemeIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
rest := raw[schemeIdx+len("://"):]
|
||||
atIdx := strings.Index(rest, "@")
|
||||
if atIdx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return rest[:atIdx], true
|
||||
}
|
||||
|
||||
func newFinding(rule, file string, line int, source, excerpt string) Finding {
|
||||
return Finding{
|
||||
Rule: rule,
|
||||
Action: actionForRule(rule),
|
||||
File: file,
|
||||
Line: line,
|
||||
Source: source,
|
||||
Excerpt: excerpt,
|
||||
Message: messageForRule(rule),
|
||||
Suggestion: suggestionForRule(rule),
|
||||
}
|
||||
}
|
||||
|
||||
func messageForRule(rule string) string {
|
||||
switch rule {
|
||||
case "public_content_generic_credential":
|
||||
return "public contribution contains a generic credential assignment"
|
||||
case "public_content_private_key_block":
|
||||
return "public contribution contains a private key block"
|
||||
case "public_content_jwt_like_token":
|
||||
return "public contribution contains a JWT-like token"
|
||||
case "public_content_bearer_header":
|
||||
return "public contribution contains an Authorization bearer token"
|
||||
case "public_content_credential_url":
|
||||
return "public contribution contains credentials embedded in a URL"
|
||||
case "public_content_private_ipv4":
|
||||
return "public contribution contains a private-network IP address"
|
||||
case "public_content_automation_branch":
|
||||
return "public contribution uses an automation-shaped branch name"
|
||||
case "public_content_change_id_trailer":
|
||||
return "public contribution contains a Change-Id trailer"
|
||||
case "public_content_reviewed_on_trailer":
|
||||
return "public contribution contains a Reviewed-on trailer"
|
||||
case "public_content_provenance_marker":
|
||||
return "public contribution contains a prohibited provenance marker"
|
||||
case "public_content_detector_fingerprint":
|
||||
return "public rule/config content exposes public detector fingerprints"
|
||||
case "public_content_harness_metadata":
|
||||
return "public contribution contains visible harness pipeline metadata"
|
||||
case "public_content_ccm_harness_trailer":
|
||||
return "public contribution contains a CCM-Harness trailer"
|
||||
case "public_content_semantic_candidate":
|
||||
return "public contribution contains text for semantic public content review"
|
||||
default:
|
||||
return "public contribution contains content that should not be published"
|
||||
}
|
||||
}
|
||||
|
||||
func suggestionForRule(rule string) string {
|
||||
switch actionForRule(rule) {
|
||||
case "REJECT":
|
||||
return "remove the value from the public contribution and replace it with a non-sensitive placeholder"
|
||||
default:
|
||||
return "remove private workflow metadata before publishing the public contribution"
|
||||
}
|
||||
}
|
||||
|
||||
func redactAssignment(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "<credential-assignment>"
|
||||
}
|
||||
return fmt.Sprintf("%s= <redacted>", strings.TrimSpace(key))
|
||||
}
|
||||
|
||||
func credentialAssignmentKey(match string) (string, bool) {
|
||||
idx := -1
|
||||
for _, sep := range []string{":", "="} {
|
||||
if candidate := strings.Index(match, sep); candidate >= 0 && (idx < 0 || candidate < idx) {
|
||||
idx = candidate
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
return match[:idx], true
|
||||
}
|
||||
|
||||
func redactToken(_ string) string {
|
||||
return "<jwt-like-token>"
|
||||
}
|
||||
|
||||
func redactedSemanticExcerpt(text string) string {
|
||||
normalized := strings.Join(strings.Fields(text), " ")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
signals := semanticSignals(normalized)
|
||||
if len(signals) == 0 {
|
||||
return ""
|
||||
}
|
||||
sanitized := truncateRunes(sanitizeSemanticExcerpt(text), 600)
|
||||
return fmt.Sprintf("semantic signals: %s; excerpt: %q", strings.Join(signals, ","), sanitized)
|
||||
}
|
||||
|
||||
func semanticSignals(normalized string) []string {
|
||||
lower := strings.ToLower(normalized)
|
||||
var signals []string
|
||||
add := func(signal string) {
|
||||
for _, existing := range signals {
|
||||
if existing == signal {
|
||||
return
|
||||
}
|
||||
}
|
||||
signals = append(signals, signal)
|
||||
}
|
||||
|
||||
hasPrivateScope := strings.Contains(lower, "private") || strings.Contains(lower, "internal-only")
|
||||
hasRequestMetadata := strings.Contains(lower, "request header") || strings.Contains(lower, "request headers") || strings.Contains(lower, "authorization header") || strings.Contains(lower, "metadata header")
|
||||
hasTrustBoundary := strings.Contains(lower, "spoof") || strings.Contains(lower, "trust") || strings.Contains(lower, "risk scoring") || strings.Contains(lower, "classification")
|
||||
hasRoadmap := strings.Contains(lower, "roadmap") || strings.Contains(lower, "migration") || strings.Contains(lower, "rollout") || strings.Contains(lower, "cutover") || strings.Contains(lower, "unpublished")
|
||||
hasTiming := strings.Contains(lower, "target date") || strings.Contains(lower, "friday") || strings.Contains(lower, "monday") || strings.Contains(lower, "tuesday") || strings.Contains(lower, "wednesday") || strings.Contains(lower, "thursday") || strings.Contains(lower, "customer-visible")
|
||||
hasImplementation := strings.Contains(lower, "server-side") || strings.Contains(lower, "implementation")
|
||||
|
||||
if hasPrivateScope && hasRequestMetadata && hasTrustBoundary {
|
||||
add("private_scope")
|
||||
add("request_metadata")
|
||||
add("trust_boundary_detail")
|
||||
}
|
||||
if hasRoadmap && (hasPrivateScope || hasTiming) {
|
||||
add("roadmap_detail")
|
||||
if hasPrivateScope {
|
||||
add("private_scope")
|
||||
}
|
||||
if hasTiming {
|
||||
add("roadmap_timing")
|
||||
}
|
||||
}
|
||||
if hasPrivateScope && hasImplementation && hasTrustBoundary {
|
||||
add("private_scope")
|
||||
add("implementation_detail")
|
||||
add("trust_boundary_detail")
|
||||
}
|
||||
|
||||
return signals
|
||||
}
|
||||
|
||||
func sanitizeSemanticExcerpt(text string) string {
|
||||
text = redactPrivateKeyBlocks(text)
|
||||
text = credentialAssignmentRE.ReplaceAllStringFunc(text, sanitizeCredentialAssignment)
|
||||
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
|
||||
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
|
||||
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
|
||||
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
|
||||
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
func redactPrivateKeyBlocks(text string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
var out []string
|
||||
inPrivateKey := false
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, privateKeyBeginPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
out = append(out, "<private-key-block>")
|
||||
inPrivateKey = true
|
||||
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inPrivateKey {
|
||||
if strings.Contains(line, privateKeyEndPrefix) && strings.Contains(line, privateKeyMarker) {
|
||||
inPrivateKey = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func sanitizeCredentialAssignment(match string) string {
|
||||
key, ok := credentialAssignmentKey(match)
|
||||
if !ok {
|
||||
return "<credential-assignment>"
|
||||
}
|
||||
return strings.TrimSpace(key) + "=<redacted>"
|
||||
}
|
||||
|
||||
func sanitizeCredentialURL(raw string) string {
|
||||
redacted := redactCredentialURL(raw)
|
||||
redacted = strings.ReplaceAll(redacted, "%3Cuser%3E", "<user>")
|
||||
redacted = strings.ReplaceAll(redacted, "%3Credacted%3E", "<redacted>")
|
||||
return redacted
|
||||
}
|
||||
|
||||
func truncateRunes(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= limit {
|
||||
return text
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
1056
internal/qualitygate/publiccontent/scan_test.go
Normal file
1056
internal/qualitygate/publiccontent/scan_test.go
Normal file
File diff suppressed because it is too large
Load Diff
30
internal/qualitygate/publiccontent/types.go
Normal file
30
internal/qualitygate/publiccontent/types.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package publiccontent
|
||||
|
||||
import "github.com/larksuite/cli/internal/qualitygate/report"
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
ChangedFrom string
|
||||
MetadataPath string
|
||||
BranchName string
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
|
||||
type Finding struct {
|
||||
Rule string
|
||||
Action report.Action
|
||||
File string
|
||||
Line int
|
||||
Source string
|
||||
Excerpt string
|
||||
Message string
|
||||
Suggestion string
|
||||
}
|
||||
@@ -174,8 +174,9 @@ type materializedExample struct {
|
||||
}
|
||||
|
||||
type placeholderContext struct {
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagName string
|
||||
FlagUsage string
|
||||
FlagDefault string
|
||||
}
|
||||
|
||||
func materializePlaceholderExample(raw string, cmd manifest.Command) (materializedExample, bool) {
|
||||
@@ -247,6 +248,7 @@ func placeholderContextForFlag(name string, flag *manifest.Flag) placeholderCont
|
||||
ctx := placeholderContext{FlagName: name}
|
||||
if flag != nil {
|
||||
ctx.FlagUsage = flag.Usage
|
||||
ctx.FlagDefault = flag.DefValue
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -309,11 +311,17 @@ func fakeValueForPlaceholder(raw string, ctx placeholderContext) (string, bool)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
if value, ok := fakeNumericValueForPlaceholder(name, ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeContextualURLValueForPlaceholder(name, ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeValueFromPlaceholderName(name); ok {
|
||||
return value, true
|
||||
}
|
||||
if isGenericPlaceholderName(name) {
|
||||
return fakeValueFromUsageHint(ctx.FlagUsage)
|
||||
return fakeValueFromContextHint(ctx)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -336,16 +344,26 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
return "file_test123", true
|
||||
case hasPlaceholderToken(tokens, "file") && hasPlaceholderToken(tokens, "token"):
|
||||
return "file_test123", true
|
||||
case hasPlaceholderToken(tokens, "folder") && hasPlaceholderToken(tokens, "token"):
|
||||
return "fld_test123", true
|
||||
case hasPlaceholderToken(tokens, "image", "img"):
|
||||
return "img_test123", true
|
||||
case hasPlaceholderToken(tokens, "app"):
|
||||
return "app_test123", true
|
||||
case hasPlaceholderToken(tokens, "draft"):
|
||||
return "draft_test123", true
|
||||
case hasPlaceholderToken(tokens, "label"):
|
||||
return "label_test123", true
|
||||
case hasPlaceholderToken(tokens, "share"):
|
||||
return "share_test123", true
|
||||
case hasPlaceholderToken(tokens, "doc", "document"):
|
||||
return "doc_test123", true
|
||||
case hasPlaceholderToken(tokens, "sheet", "spreadsheet"):
|
||||
return "shtcn_test123", true
|
||||
case hasPlaceholderToken(tokens, "base"):
|
||||
return "base_test123", true
|
||||
case hasPlaceholderToken(tokens, "space"):
|
||||
return "space_test123", true
|
||||
case hasPlaceholderToken(tokens, "table"):
|
||||
return "tbl_test123", true
|
||||
case hasPlaceholderToken(tokens, "view"):
|
||||
@@ -377,17 +395,98 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func fakeValueFromUsageHint(usage string) (string, bool) {
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(usage))
|
||||
func fakeValueFromContextHint(ctx placeholderContext) (string, bool) {
|
||||
if value, ok := fakeNumericValueForPlaceholder("", ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
if value, ok := fakeContextualURLValueForPlaceholder("", ctx); ok {
|
||||
return value, true
|
||||
}
|
||||
match := placeholderValuePattern.FindStringSubmatch(strings.ToLower(ctx.FlagUsage))
|
||||
if len(match) != 2 || !knownTokenPrefix(match[1]) {
|
||||
return "", false
|
||||
}
|
||||
return match[1] + "_test123", true
|
||||
}
|
||||
|
||||
func fakeContextualURLValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
|
||||
nameTokens := placeholderTokenSet(name)
|
||||
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
|
||||
flagTokens := placeholderTokenSet(flagName)
|
||||
if !hasPlaceholderToken(nameTokens, "url", "link") && !hasPlaceholderToken(flagTokens, "url", "link") {
|
||||
return "", false
|
||||
}
|
||||
usage := strings.ToLower(ctx.FlagUsage)
|
||||
if strings.Contains(usage, "lark") || strings.Contains(usage, "feishu") || strings.Contains(usage, "document url") {
|
||||
return "https://example.feishu.cn/docx/doc_test123", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fakeNumericValueForPlaceholder(name string, ctx placeholderContext) (string, bool) {
|
||||
nameTokens := placeholderTokenSet(name)
|
||||
flagName := strings.ReplaceAll(strings.ToLower(ctx.FlagName), "-", "_")
|
||||
flagTokens := placeholderTokenSet(flagName)
|
||||
usage := strings.ToLower(ctx.FlagUsage)
|
||||
|
||||
switch {
|
||||
case placeholderTokenPair(nameTokens, "meeting", "id") || placeholderTokenPair(flagTokens, "meeting", "id"):
|
||||
return "400000000001", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "ids") || placeholderTokenPair(flagTokens, "meeting", "ids"):
|
||||
return "400000000001", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "no") || placeholderTokenPair(flagTokens, "meeting", "no"):
|
||||
return "123456789", true
|
||||
case placeholderTokenPair(nameTokens, "meeting", "number") || placeholderTokenPair(flagTokens, "meeting", "number"):
|
||||
return "123456789", true
|
||||
case hasPlaceholderToken(nameTokens, "timestamp") || hasPlaceholderToken(flagTokens, "timestamp") || strings.Contains(usage, "unix timestamp"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "1893456000"), true
|
||||
case placeholderTokenPair(nameTokens, "page", "size") || placeholderTokenPair(flagTokens, "page", "size"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
|
||||
case placeholderTokenPair(nameTokens, "page", "limit") || placeholderTokenPair(flagTokens, "page", "limit"):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "10"), true
|
||||
case numericPlaceholderName(nameTokens) || numericPlaceholderName(flagTokens) || numericUsageHint(usage):
|
||||
return defaultPositiveInteger(ctx.FlagDefault, "20"), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func numericPlaceholderName(tokens map[string]bool) bool {
|
||||
if len(tokens) == 0 || hasPlaceholderToken(tokens, "token", "format", "type", "status", "mode") {
|
||||
return false
|
||||
}
|
||||
return hasPlaceholderToken(tokens,
|
||||
"amount", "count", "depth", "height", "index", "length", "limit", "max",
|
||||
"number", "revision", "size", "width",
|
||||
)
|
||||
}
|
||||
|
||||
func numericUsageHint(usage string) bool {
|
||||
if usage == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(usage, "positive integer") ||
|
||||
strings.Contains(usage, "decimal integer") ||
|
||||
strings.Contains(usage, "number of ") ||
|
||||
strings.Contains(usage, "(number)")
|
||||
}
|
||||
|
||||
func defaultPositiveInteger(raw, fallback string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "-") || raw == "0" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range raw {
|
||||
if r < '0' || r > '9' {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func knownTokenPrefix(prefix string) bool {
|
||||
switch prefix {
|
||||
case "app", "base", "doc", "file", "fld", "img", "item", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "shtcn", "task", "tbl", "token", "viw", "wiki":
|
||||
case "app", "base", "doc", "draft", "file", "fld", "img", "item", "label", "meeting", "obcn", "oc", "od", "om", "ou", "page", "rec", "share", "shtcn", "space", "task", "tbl", "token", "viw", "wiki":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -431,6 +530,10 @@ func hasPlaceholderToken(tokens map[string]bool, wants ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func placeholderTokenPair(tokens map[string]bool, first, second string) bool {
|
||||
return tokens[first] && tokens[second]
|
||||
}
|
||||
|
||||
func hasUnresolvedDryRunPlaceholder(value string) bool {
|
||||
if skillscan.HasPlaceholder(value) {
|
||||
return true
|
||||
@@ -623,6 +726,7 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return nil, fmt.Errorf("not a lark-cli command")
|
||||
}
|
||||
argv = truncateShellTail(argv)
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
hasDryRunArg := false
|
||||
dryRunEnabled := false
|
||||
for _, arg := range argv[1:] {
|
||||
@@ -642,6 +746,23 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return append(argv[1:], "--dry-run"), nil
|
||||
}
|
||||
|
||||
func forceDryRunJSONFormat(argv []string) []string {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
if arg == "--format" {
|
||||
if i+1 < len(argv) && argv[i+1] == "pretty" {
|
||||
argv[i+1] = "json"
|
||||
}
|
||||
return argv
|
||||
}
|
||||
if arg == "--format=pretty" {
|
||||
argv[i] = "--format=json"
|
||||
return argv
|
||||
}
|
||||
}
|
||||
return argv
|
||||
}
|
||||
|
||||
func truncateShellTail(argv []string) []string {
|
||||
for i, arg := range argv {
|
||||
if i == 0 {
|
||||
|
||||
@@ -305,6 +305,161 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "vc +meeting-events",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "meeting-id", TakesValue: true, Usage: "meeting ID to query; must be a long positive integer, not a 9-digit meeting number"},
|
||||
{Name: "page-size", TakesValue: true, Usage: "page size, 20-100 (default 50)", DefValue: "50"},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli vc +meeting-events --meeting-id <meeting_id> --page-size <page_size>",
|
||||
SourceFile: "skills/lark-vc-agent/SKILL.md",
|
||||
Line: 120,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("numeric placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--page-size", "50", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesNumericPlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/test","params":{"timestamp":"1893456000","count":"20"}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "api GET",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "params", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli api GET /open-apis/test --params '{"timestamp":"<timestamp>","count":"<count>"}'`,
|
||||
SourceFile: "skills/lark-demo/SKILL.md",
|
||||
Line: 20,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("JSON numeric placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"api", "GET", "/open-apis/test", "--params", `{"timestamp":"1893456000","count":"20"}`, "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesLarkDocumentURLPlaceholders(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/drive/v1/metas/batch_query"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "drive +inspect",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "url", TakesValue: true, Usage: "Lark/Feishu document URL (docx, doc, sheet, bitable, wiki, file, folder, mindnote, slides)"},
|
||||
{Name: "format", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli drive +inspect --url '<url>' --format json",
|
||||
SourceFile: "skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md",
|
||||
Line: 15,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("Lark URL placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"drive", "+inspect", "--url", "https://example.feishu.cn/docx/doc_test123", "--format", "json", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesResourceIDPlaceholderFlagValues(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/wiki/v2/spaces/space_test123/nodes"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "wiki +node-list",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "space-id", TakesValue: true, Usage: "wiki space ID"},
|
||||
{Name: "page-token", TakesValue: true, Usage: "page token"},
|
||||
{Name: "format", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: "lark-cli wiki +node-list --space-id <space_id> --page-token <PAGE_TOKEN> --format json",
|
||||
SourceFile: "skills/lark-wiki/references/lark-wiki-node-list.md",
|
||||
Line: 24,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("resource ID placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"wiki", "+node-list", "--space-id", "space_test123", "--page-token", "page_test123", "--format", "json", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesResourcePlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"POST","url":"/open-apis/mail/v1/user_mailboxes/me/drafts/draft_test123/send"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "mail user_mailbox.drafts send",
|
||||
Runnable: true,
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "params", TakesValue: true},
|
||||
{Name: "data", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"me","draft_id":"<draft_id>"}' --data '{"send_time":"<unix_timestamp>"}'`,
|
||||
SourceFile: "skills/lark-mail/references/lark-mail-send.md",
|
||||
Line: 172,
|
||||
HasPlaceholder: true,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("JSON resource placeholder example should be executable after materialization: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"mail", "user_mailbox.drafts", "send", "--params", `{"user_mailbox_id":"me","draft_id":"draft_test123"}`, "--data", `{"send_time":"1893456000"}`, "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsSkipsUnknownFlagsBeforeDryRun(t *testing.T) {
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +chat-messages-list",
|
||||
@@ -600,6 +755,51 @@ func TestAppendDryRunArgDoesNotDuplicate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesJSONFormat(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format pretty")
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format", "json", "--dry-run"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli vc +meeting-events --meeting-id 400000000001 --format=pretty --dry-run")
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
want := []string{"vc", "+meeting-events", "--meeting-id", "400000000001", "--format=json", "--dry-run"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"lark-cli mail +watch --format data --dry-run",
|
||||
"lark-cli export +events --format=ndjson --dry-run",
|
||||
"lark-cli docs +fetch --format table",
|
||||
} {
|
||||
got, err := appendDryRunArg(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg(%q) error = %v", raw, err)
|
||||
}
|
||||
for _, arg := range got {
|
||||
if arg == "--format=json" {
|
||||
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote inline format: %#v", raw, got)
|
||||
}
|
||||
}
|
||||
for i, arg := range got {
|
||||
if arg == "--format" && i+1 < len(got) && got[i+1] == "json" {
|
||||
t.Fatalf("appendDryRunArg(%q) unexpectedly rewrote split format: %#v", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgForcesDryRunWhenExplicitlyDisabled(t *testing.T) {
|
||||
got, err := appendDryRunArg("lark-cli docs +fetch --dry-run=false --doc abc")
|
||||
if err != nil {
|
||||
|
||||
@@ -15,18 +15,20 @@ import (
|
||||
manifestexamples "github.com/larksuite/cli/internal/qualitygate/examples"
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
"github.com/larksuite/cli/internal/qualitygate/skillscan"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
Repo string
|
||||
CLIBin string
|
||||
ChangedFrom string
|
||||
FactsOut string
|
||||
ManifestPath string
|
||||
CommandIndexPath string
|
||||
PublicContentMetadataPath string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, error) {
|
||||
@@ -98,9 +100,60 @@ func Run(ctx context.Context, opts Options) ([]report.Diagnostic, facts.Facts, e
|
||||
if opts.ChangedFrom != "" {
|
||||
diags = append(diags, errorDiags...)
|
||||
}
|
||||
publicContent, err := publiccontent.Collect(ctx, publiccontent.Options{
|
||||
Repo: opts.Repo,
|
||||
ChangedFrom: opts.ChangedFrom,
|
||||
MetadataPath: opts.PublicContentMetadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, facts.Facts{}, err
|
||||
}
|
||||
diags = append(diags, publicContentDiagnostics(publicContent)...)
|
||||
diags = filterPRDiagnostics(opts.Repo, opts.ChangedFrom, scope, m, diags)
|
||||
|
||||
return diags, facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files), nil
|
||||
builtFacts := facts.BuildWithCommandLookup(m, commandIndex, skillFacts, skillQualityFacts, errorFacts, exampleFacts, outputFacts, diags, scope.Files)
|
||||
return diags, facts.WithPublicContent(builtFacts, publicContentFacts(publicContent)), nil
|
||||
}
|
||||
|
||||
func publicContentDiagnostics(items []publiccontent.Finding) []report.Diagnostic {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]report.Diagnostic, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Rule == "public_content_semantic_candidate" {
|
||||
continue
|
||||
}
|
||||
out = append(out, report.Diagnostic{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func publicContentFacts(items []publiccontent.Finding) []facts.PublicContentFact {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]facts.PublicContentFact, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, facts.PublicContentFact{
|
||||
Rule: item.Rule,
|
||||
Action: item.Action,
|
||||
File: item.File,
|
||||
Line: item.Line,
|
||||
Source: item.Source,
|
||||
Excerpt: item.Excerpt,
|
||||
Message: item.Message,
|
||||
Suggestion: item.Suggestion,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readManifestInput(path, kind, flag string) (manifest.Manifest, error) {
|
||||
@@ -167,6 +220,9 @@ func filterPRDiagnostics(repo, changedFrom string, scope qdiff.Scope, m manifest
|
||||
}
|
||||
|
||||
func prDiagnosticRelevant(repo string, changedFiles map[string]bool, commandScope diagnosticCommandScope, m manifest.Manifest, diag report.Diagnostic) bool {
|
||||
if strings.HasPrefix(diag.Rule, "public_content_") {
|
||||
return true
|
||||
}
|
||||
file := normalizeDiagnosticFile(repo, diag.File)
|
||||
if file != "" && changedFiles[file] {
|
||||
return true
|
||||
|
||||
@@ -189,6 +189,99 @@ description: Manage Drive comments with service command references.
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
runGit(t, repo, "config", "user.email", "test@example.com")
|
||||
runGit(t, repo, "config", "user.name", "Test User")
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "README.md"), []byte("# test\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repo, "add", "README.md")
|
||||
runGit(t, repo, "commit", "-m", "base")
|
||||
|
||||
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDoc := "api_" + "key = \"example-public-key\"\n" +
|
||||
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
|
||||
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runGit(t, repo, "add", "docs/public.md")
|
||||
runGit(t, repo, "commit", "-m", "add public doc")
|
||||
|
||||
metadataPath := filepath.Join(repo, "pr-metadata.json")
|
||||
if err := vfs.WriteFile(metadataPath, []byte(`{"title":"public docs","body":"Change`+`-Id: I0123456789abcdef0123456789abcdef01234567"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manifestPath := filepath.Join(repo, "command-manifest.json")
|
||||
indexPath := filepath.Join(repo, "command-index.json")
|
||||
m := manifest.Manifest{SchemaVersion: 1, Commands: []manifest.Command{{
|
||||
Path: "docs +fetch",
|
||||
CanonicalPath: "docs +fetch",
|
||||
Domain: "docs",
|
||||
Source: manifest.SourceShortcut,
|
||||
}}}
|
||||
if err := manifest.WriteFile(manifestPath, manifest.KindCommandManifest, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := manifest.Manifest{SchemaVersion: 1, Commands: append([]manifest.Command{}, m.Commands...)}
|
||||
idx.Commands = append(idx.Commands, manifest.Command{
|
||||
Path: "drive files get",
|
||||
CanonicalPath: "drive files get",
|
||||
Domain: "drive",
|
||||
Source: manifest.SourceService,
|
||||
Generated: true,
|
||||
Runnable: true,
|
||||
})
|
||||
if err := manifest.WriteFile(indexPath, manifest.KindCommandIndex, idx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
diags, gotFacts, err := Run(context.Background(), Options{
|
||||
Repo: repo,
|
||||
CLIBin: "./lark-cli",
|
||||
ChangedFrom: "HEAD~1",
|
||||
ManifestPath: manifestPath,
|
||||
CommandIndexPath: indexPath,
|
||||
PublicContentMetadataPath: metadataPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
actions := map[string]report.Action{}
|
||||
for _, diag := range diags {
|
||||
actions[diag.Rule] = diag.Action
|
||||
}
|
||||
if actions["public_content_generic_credential"] != report.ActionReject {
|
||||
t.Fatalf("generic credential diagnostic action = %q, diagnostics=%#v", actions["public_content_generic_credential"], diags)
|
||||
}
|
||||
if actions["public_content_change_id_trailer"] != report.ActionReject {
|
||||
t.Fatalf("change-id diagnostic action = %q, diagnostics=%#v", actions["public_content_change_id_trailer"], diags)
|
||||
}
|
||||
if actions["public_content_semantic_candidate"] != "" {
|
||||
t.Fatalf("semantic candidates should not become deterministic diagnostics: %#v", diags)
|
||||
}
|
||||
factRules := map[string]bool{}
|
||||
for _, item := range gotFacts.PublicContent {
|
||||
factRules[item.Rule] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"public_content_generic_credential",
|
||||
"public_content_change_id_trailer",
|
||||
"public_content_semantic_candidate",
|
||||
} {
|
||||
if !factRules[want] {
|
||||
t.Fatalf("missing public content fact %s: %#v", want, gotFacts.PublicContent)
|
||||
}
|
||||
}
|
||||
if len(gotFacts.PublicContent) < 3 {
|
||||
t.Fatalf("public content facts = %#v", gotFacts.PublicContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBaseReferenceManifestReadsCommandGolden(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
runGit(t, repo, "init")
|
||||
@@ -506,7 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
|
||||
|
||||
func runGit(t *testing.T, repo string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
||||
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -339,7 +339,7 @@ func jsonSchemaResponseFormat() map[string]any {
|
||||
"properties": map[string]any{
|
||||
"category": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
"enum": []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
},
|
||||
"severity": map[string]any{
|
||||
"type": "string",
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
)
|
||||
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs)\[(\d+)\]$`)
|
||||
var evidencePattern = regexp.MustCompile(`^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$`)
|
||||
|
||||
func Decide(f facts.Facts, r Review, p Policy) Decision {
|
||||
return DecideWithWaivers(f, r, p, Waivers{})
|
||||
@@ -172,6 +173,16 @@ func evidenceFingerprint(f facts.Facts, ev string) string {
|
||||
"has_default_limit:" + strconv.FormatBool(out.HasDefaultLimit),
|
||||
"has_decision_field:" + strconv.FormatBool(out.HasDecisionField),
|
||||
}, ":")
|
||||
case "public_content":
|
||||
item := f.PublicContent[idx]
|
||||
return strings.Join([]string{
|
||||
"public_content",
|
||||
"rule:" + item.Rule,
|
||||
"action:" + string(item.Action),
|
||||
"file:" + item.File,
|
||||
"line:" + strconv.Itoa(item.Line),
|
||||
"source:" + item.Source,
|
||||
}, ":")
|
||||
default:
|
||||
return "ref:" + ev
|
||||
}
|
||||
@@ -201,7 +212,7 @@ func validFinding(f Finding) bool {
|
||||
|
||||
func allowedCategory(category string) bool {
|
||||
switch category {
|
||||
case "error_hint", "default_output", "naming", "skill_quality":
|
||||
case "error_hint", "default_output", "naming", "skill_quality", "public_content_leakage":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -247,6 +258,12 @@ func reproducibleEvidence(f facts.Facts, category, kind string, idx int) bool {
|
||||
}
|
||||
skill := f.Skills[idx]
|
||||
return skill.ReferencesInvalidCommand
|
||||
case "public_content_leakage":
|
||||
if kind != "public_content" {
|
||||
return false
|
||||
}
|
||||
item := f.PublicContent[idx]
|
||||
return item.Action == report.ActionReject || item.Rule == "public_content_semantic_candidate"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -277,6 +294,8 @@ func evidenceExists(f facts.Facts, kind string, idx int) bool {
|
||||
return idx < len(f.Errors)
|
||||
case "outputs":
|
||||
return idx < len(f.Outputs)
|
||||
case "public_content":
|
||||
return idx < len(f.PublicContent)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
Outputs: []facts.OutputFact{{Command: "im messages list", IsList: true, HasDefaultLimit: false, HasDecisionField: false}},
|
||||
Commands: []facts.CommandFact{{Path: "docs fetch", NameConflictsExisting: true}},
|
||||
Skills: []facts.SkillFact{{SourceFile: "skills/lark-doc/SKILL.md", Line: 3, ReferencesInvalidCommand: true}},
|
||||
PublicContent: []facts.PublicContentFact{{Rule: "public_content_generic_credential", Action: "REJECT", File: "docs/public.md", Line: 4, Source: "metadata"}},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
category string
|
||||
@@ -251,6 +252,7 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
{"default_output", "facts.outputs[0]"},
|
||||
{"naming", "facts.commands[0]"},
|
||||
{"skill_quality", "facts.skills[0]"},
|
||||
{"public_content_leakage", "facts.public_content[0]"},
|
||||
} {
|
||||
t.Run(tc.category, func(t *testing.T) {
|
||||
r := Review{Findings: []Finding{{
|
||||
@@ -268,6 +270,59 @@ func TestGatekeeperBlockerMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperDoesNotPromotePublicContentWarningsToBlockers(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
Action: "WARNING",
|
||||
File: "docs/network.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "minor",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "pri" + "vate network address appears in public docs",
|
||||
SuggestedAction: "confirm the public docs do not expose pri" + "vate deployment details",
|
||||
}}}
|
||||
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 0 || len(got.Warnings) != 1 {
|
||||
t.Fatalf("public content warning should not become a blocker: %#v", got)
|
||||
}
|
||||
if got.Warnings[0].ReviewAction != ReviewActionObserve {
|
||||
t.Fatalf("review action = %q, want %q", got.Warnings[0].ReviewAction, ReviewActionObserve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperAllowsPublicContentSemanticCandidatesAsBlockers(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: "WARNING",
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "major",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "semantic review found pri" + "vate rollout detail",
|
||||
SuggestedAction: "remove pri" + "vate rollout detail from public docs",
|
||||
}}}
|
||||
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 1 {
|
||||
t.Fatalf("semantic candidate should remain blockable, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperSkillQualityOnlyBlocksInvalidCommandReferences(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
|
||||
@@ -24,7 +24,7 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
"Use only the provided JSON view.",
|
||||
"The changed_summary may summarize broad changed surfaces; review only listed facts, not omitted summarized items.",
|
||||
"Use fact_ref values exactly when writing finding evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
"facts.examples and facts.skill_quality entries are context only.",
|
||||
"Report an error_hint finding for any facts.errors item where boundary is true, required_hint is true, and hint_action_count is 0.",
|
||||
@@ -38,6 +38,9 @@ func BuildPrompt(f facts.Facts) []Message {
|
||||
"For naming findings, use category \"naming\" and evidence containing that facts.commands fact_ref.",
|
||||
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
|
||||
"For skill_quality findings, use category \"skill_quality\" and evidence containing that facts.skills fact_ref.",
|
||||
"Review public content leakage findings and semantic candidates without private dictionaries.",
|
||||
"Do not reveal internal rule lists when explaining public content leakage.",
|
||||
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
|
||||
"Report each distinct issue as a separate finding.",
|
||||
"The verdict value must be \"pass\" when findings is empty and \"warn\" when findings is non-empty; never use \"fail\".",
|
||||
"Severity must be one of \"minor\", \"major\", or \"critical\"; never use \"error\", \"warning\", \"medium\", or \"high\".",
|
||||
|
||||
@@ -23,7 +23,10 @@ func TestBuildPromptContainsSemanticReviewContract(t *testing.T) {
|
||||
"A facts.outputs item with is_list true, has_default_limit false, and has_decision_field true must still produce a default_output finding.",
|
||||
"Report a naming finding for any facts.commands item where name_conflicts_existing is true or flag_alias_conflict is true.",
|
||||
"Report a skill_quality finding for any facts.skills item where references_invalid_command is true.",
|
||||
"Only facts.commands, facts.skills, facts.errors, and facts.outputs fact_ref values may be blocker evidence.",
|
||||
"Review public content leakage findings and semantic candidates without private dictionaries.",
|
||||
"Do not reveal internal rule lists when explaining public content leakage.",
|
||||
"For public_content_leakage findings, preserve the deterministic finding source and excerpt.",
|
||||
"Only facts.commands, facts.skills, facts.errors, facts.outputs, and facts.public_content fact_ref values may be blocker evidence.",
|
||||
"Evidence entries must be exact fact_ref strings such as \"facts.commands[0]\" with no explanations, labels, or suffix text.",
|
||||
"facts.examples and facts.skill_quality entries are context only.",
|
||||
"Report each distinct issue as a separate finding.",
|
||||
|
||||
@@ -78,11 +78,11 @@ func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
SchemaVersion: 1,
|
||||
DefaultEnforcement: "observe",
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
BlockCategories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
RolloutGroups: []RolloutGroup{{
|
||||
ID: "all",
|
||||
Enforcement: "blocking",
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality"},
|
||||
Categories: []string{"error_hint", "default_output", "naming", "skill_quality", "public_content_leakage"},
|
||||
Owner: "test",
|
||||
Reason: "default in-memory policy",
|
||||
}},
|
||||
|
||||
@@ -82,6 +82,15 @@ func factScope(f facts.Facts, kind string, idx int) (FactScope, bool) {
|
||||
Source: item.Source,
|
||||
CommandPath: item.Command,
|
||||
}, true
|
||||
case "public_content":
|
||||
item := f.PublicContent[idx]
|
||||
return FactScope{
|
||||
FactKind: "public_content",
|
||||
Changed: true,
|
||||
Source: item.Source,
|
||||
SourceFile: item.File,
|
||||
Line: item.Line,
|
||||
}, true
|
||||
default:
|
||||
return FactScope{}, false
|
||||
}
|
||||
@@ -195,7 +204,7 @@ func containsString(values []string, want string) bool {
|
||||
|
||||
func allowedFactKind(kind string) bool {
|
||||
switch kind {
|
||||
case "skill", "command", "error", "output":
|
||||
case "skill", "command", "error", "output", "public_content":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -81,6 +81,30 @@ func TestGatekeeperSkillQualityUsesSkillEvidence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperUsesPublicContentEvidence(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: "REJECT",
|
||||
File: "docs/public.md",
|
||||
Line: 12,
|
||||
Source: "metadata",
|
||||
}},
|
||||
}
|
||||
review := Review{Findings: []Finding{{
|
||||
Category: "public_content_leakage",
|
||||
Severity: "critical",
|
||||
Evidence: []string{"facts.public_content[0]"},
|
||||
Message: "public content finding needs review",
|
||||
SuggestedAction: "remove the sensitive public content",
|
||||
}}}
|
||||
got := Decide(f, review, DefaultPolicy())
|
||||
if len(got.Blockers) != 1 || got.Blockers[0].RolloutGroups[0] != "all" {
|
||||
t.Fatalf("expected public content blocker, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatekeeperAppliesSharedWaiverID(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
|
||||
@@ -13,27 +13,29 @@ import (
|
||||
)
|
||||
|
||||
type InputView struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChangedSummary ChangedSummary `json:"changed_summary"`
|
||||
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
|
||||
Commands []CommandInput `json:"commands,omitempty"`
|
||||
Skills []SkillInput `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorInput `json:"errors,omitempty"`
|
||||
Outputs []OutputInput `json:"outputs,omitempty"`
|
||||
Examples []ExampleInput `json:"examples,omitempty"`
|
||||
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ChangedSummary ChangedSummary `json:"changed_summary"`
|
||||
RuleSummary []RuleSummaryItem `json:"rule_summary,omitempty"`
|
||||
Commands []CommandInput `json:"commands,omitempty"`
|
||||
Skills []SkillInput `json:"skills,omitempty"`
|
||||
SkillQuality []SkillQualityInput `json:"skill_quality,omitempty"`
|
||||
Errors []ErrorInput `json:"errors,omitempty"`
|
||||
Outputs []OutputInput `json:"outputs,omitempty"`
|
||||
Examples []ExampleInput `json:"examples,omitempty"`
|
||||
PublicContentLeakage []PublicContentInput `json:"public_content_leakage,omitempty"`
|
||||
Diagnostics []facts.DiagnosticFact `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type ChangedSummary struct {
|
||||
Commands int `json:"commands,omitempty"`
|
||||
Skills int `json:"skills,omitempty"`
|
||||
SkillQuality int `json:"skill_quality,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Outputs int `json:"outputs,omitempty"`
|
||||
Examples int `json:"examples,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
Commands int `json:"commands,omitempty"`
|
||||
Skills int `json:"skills,omitempty"`
|
||||
SkillQuality int `json:"skill_quality,omitempty"`
|
||||
Errors int `json:"errors,omitempty"`
|
||||
Outputs int `json:"outputs,omitempty"`
|
||||
Examples int `json:"examples,omitempty"`
|
||||
PublicContent int `json:"public_content,omitempty"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Sources []string `json:"sources,omitempty"`
|
||||
}
|
||||
|
||||
type RuleSummaryItem struct {
|
||||
@@ -86,6 +88,22 @@ type ExampleInput struct {
|
||||
facts.CommandExample
|
||||
}
|
||||
|
||||
type PublicContentInput struct {
|
||||
FactRef string `json:"fact_ref"`
|
||||
facts.PublicContentFact
|
||||
}
|
||||
|
||||
func (v InputView) HasReviewableFacts() bool {
|
||||
return len(v.Commands) > 0 ||
|
||||
len(v.Skills) > 0 ||
|
||||
len(v.SkillQuality) > 0 ||
|
||||
len(v.Errors) > 0 ||
|
||||
len(v.Outputs) > 0 ||
|
||||
len(v.Examples) > 0 ||
|
||||
len(v.PublicContentLeakage) > 0 ||
|
||||
len(v.Diagnostics) > 0
|
||||
}
|
||||
|
||||
func BuildInputView(f facts.Facts) InputView {
|
||||
selected := newInputSelection(f)
|
||||
selected.addChangedReviewCandidates()
|
||||
@@ -104,16 +122,17 @@ func BuildInputView(f facts.Facts) InputView {
|
||||
}
|
||||
|
||||
return InputView{
|
||||
SchemaVersion: f.SchemaVersion,
|
||||
ChangedSummary: changedSummary(f),
|
||||
RuleSummary: ruleSummary(f.Diagnostics),
|
||||
Commands: selected.commandInputs(),
|
||||
Skills: selected.skillInputs(),
|
||||
SkillQuality: selected.skillQualityInputs(),
|
||||
Errors: selected.errorInputs(),
|
||||
Outputs: selected.outputInputs(),
|
||||
Examples: selected.exampleInputs(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
SchemaVersion: f.SchemaVersion,
|
||||
ChangedSummary: changedSummary(f),
|
||||
RuleSummary: ruleSummary(f.Diagnostics),
|
||||
Commands: selected.commandInputs(),
|
||||
Skills: selected.skillInputs(),
|
||||
SkillQuality: selected.skillQualityInputs(),
|
||||
Errors: selected.errorInputs(),
|
||||
Outputs: selected.outputInputs(),
|
||||
Examples: selected.exampleInputs(),
|
||||
PublicContentLeakage: selected.publicContentInputs(),
|
||||
Diagnostics: viewDiagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +157,11 @@ func (s *inputSelection) addChangedReviewCandidates() {
|
||||
s.outputs[i] = true
|
||||
}
|
||||
}
|
||||
for i, item := range s.f.PublicContent {
|
||||
if publicContentReviewCandidate(item) {
|
||||
s.publicContent[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func commandReviewCandidate(cmd facts.CommandFact) bool {
|
||||
@@ -157,25 +181,31 @@ func outputReviewCandidate(_ facts.OutputFact) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func publicContentReviewCandidate(item facts.PublicContentFact) bool {
|
||||
return item.Rule == "public_content_semantic_candidate"
|
||||
}
|
||||
|
||||
type inputSelection struct {
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []bool
|
||||
f facts.Facts
|
||||
commands []bool
|
||||
skills []bool
|
||||
skillQuality []bool
|
||||
errors []bool
|
||||
outputs []bool
|
||||
examples []bool
|
||||
publicContent []bool
|
||||
}
|
||||
|
||||
func newInputSelection(f facts.Facts) *inputSelection {
|
||||
return &inputSelection{
|
||||
f: f,
|
||||
commands: make([]bool, len(f.Commands)),
|
||||
skills: make([]bool, len(f.Skills)),
|
||||
skillQuality: make([]bool, len(f.SkillQuality)),
|
||||
errors: make([]bool, len(f.Errors)),
|
||||
outputs: make([]bool, len(f.Outputs)),
|
||||
examples: make([]bool, len(f.Examples)),
|
||||
f: f,
|
||||
commands: make([]bool, len(f.Commands)),
|
||||
skills: make([]bool, len(f.Skills)),
|
||||
skillQuality: make([]bool, len(f.SkillQuality)),
|
||||
errors: make([]bool, len(f.Errors)),
|
||||
outputs: make([]bool, len(f.Outputs)),
|
||||
examples: make([]bool, len(f.Examples)),
|
||||
publicContent: make([]bool, len(f.PublicContent)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +224,8 @@ func (s *inputSelection) diagnosticContext(diag facts.DiagnosticFact) *inputSele
|
||||
s.addDiagnosticExamples(out, diag)
|
||||
case diag.Rule == "no_bare_helper_error":
|
||||
s.addDiagnosticErrors(out, diag)
|
||||
case strings.HasPrefix(diag.Rule, "public_content_"):
|
||||
s.addDiagnosticPublicContent(out, diag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -256,6 +288,15 @@ func (s *inputSelection) addDiagnosticExamples(out *inputSelection, diag facts.D
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputSelection) addDiagnosticPublicContent(out *inputSelection, diag facts.DiagnosticFact) {
|
||||
for i, item := range s.f.PublicContent {
|
||||
if diagnosticLocationMatches(diag.File, diag.Line, item.File, item.Line) ||
|
||||
diag.Rule == item.Rule {
|
||||
out.publicContent[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func includeDiagnosticInView(diag facts.DiagnosticFact, selected, context *inputSelection) bool {
|
||||
if diag.Action == report.ActionReject {
|
||||
return true
|
||||
@@ -270,6 +311,7 @@ func (s *inputSelection) merge(other *inputSelection) {
|
||||
mergeSelections(s.errors, other.errors)
|
||||
mergeSelections(s.outputs, other.outputs)
|
||||
mergeSelections(s.examples, other.examples)
|
||||
mergeSelections(s.publicContent, other.publicContent)
|
||||
}
|
||||
|
||||
func (s *inputSelection) intersects(other *inputSelection) bool {
|
||||
@@ -278,7 +320,8 @@ func (s *inputSelection) intersects(other *inputSelection) bool {
|
||||
selectionsIntersect(s.skillQuality, other.skillQuality) ||
|
||||
selectionsIntersect(s.errors, other.errors) ||
|
||||
selectionsIntersect(s.outputs, other.outputs) ||
|
||||
selectionsIntersect(s.examples, other.examples)
|
||||
selectionsIntersect(s.examples, other.examples) ||
|
||||
selectionsIntersect(s.publicContent, other.publicContent)
|
||||
}
|
||||
|
||||
func (s *inputSelection) commandInputs() []CommandInput {
|
||||
@@ -351,6 +394,16 @@ func (s *inputSelection) exampleInputs() []ExampleInput {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *inputSelection) publicContentInputs() []PublicContentInput {
|
||||
out := make([]PublicContentInput, 0, countSelected(s.publicContent))
|
||||
for i, ok := range s.publicContent {
|
||||
if ok {
|
||||
out = append(out, PublicContentInput{FactRef: factRef("public_content", i), PublicContentFact: s.f.PublicContent[i]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func changedSummary(f facts.Facts) ChangedSummary {
|
||||
domains := map[string]bool{}
|
||||
sources := map[string]bool{}
|
||||
@@ -402,6 +455,10 @@ func changedSummary(f facts.Facts) ChangedSummary {
|
||||
addNonEmpty(domains, example.Domain)
|
||||
addNonEmpty(sources, example.Source)
|
||||
}
|
||||
for _, item := range f.PublicContent {
|
||||
out.PublicContent++
|
||||
addNonEmpty(sources, item.Source)
|
||||
}
|
||||
out.Domains = sortedViewSetKeys(domains)
|
||||
out.Sources = sortedViewSetKeys(sources)
|
||||
return out
|
||||
@@ -434,7 +491,8 @@ func semanticDiagnosticRule(rule string) bool {
|
||||
strings.HasPrefix(rule, "default_output") ||
|
||||
strings.HasPrefix(rule, "skill_") ||
|
||||
strings.HasPrefix(rule, "example_dry_run") ||
|
||||
rule == "no_bare_helper_error"
|
||||
rule == "no_bare_helper_error" ||
|
||||
strings.HasPrefix(rule, "public_content_")
|
||||
}
|
||||
|
||||
func diagnosticCommandMatches(diag facts.DiagnosticFact, values ...string) bool {
|
||||
|
||||
@@ -77,6 +77,122 @@ func TestInputViewKeepsChangedReviewCandidatesWithOriginalRefs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewIncludesPublicContentLeakage(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: report.ActionReject,
|
||||
File: "docs/public.md",
|
||||
Line: 4,
|
||||
Excerpt: "api_key = <redacted>",
|
||||
Message: "generic credential assignment",
|
||||
}},
|
||||
Diagnostics: []facts.DiagnosticFact{{
|
||||
Rule: "public_content_generic_credential",
|
||||
Action: report.ActionReject,
|
||||
File: "docs/public.md",
|
||||
Line: 4,
|
||||
Message: "generic credential assignment",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("public content leakage len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
|
||||
t.Fatalf("public content fact ref = %q", got)
|
||||
}
|
||||
if len(view.Diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics len = %d, want 1", len(view.Diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewIncludesPublicContentSemanticCandidatesWithoutDiagnostics(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: "public prose that needs semantic review",
|
||||
Message: "public contribution contains text for semantic public content review",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].FactRef; got != "facts.public_content[0]" {
|
||||
t.Fatalf("semantic candidate fact ref = %q", got)
|
||||
}
|
||||
if len(view.Diagnostics) != 0 {
|
||||
t.Fatalf("semantic candidate should not require diagnostics, got %#v", view.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptIncludesSanitizedPublicContentExcerpt(t *testing.T) {
|
||||
scopeText := "pri" + "vate rollout"
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_semantic_candidate",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/public.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: `semantic signals: pri` + `vate_scope,roadmap_detail; excerpt: "` + scopeText + ` token=<redacted>"`,
|
||||
Message: "public contribution contains text for semantic public content review",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 1 {
|
||||
t.Fatalf("semantic candidate len = %d, want 1", len(view.PublicContentLeakage))
|
||||
}
|
||||
if got := view.PublicContentLeakage[0].Excerpt; !strings.Contains(got, scopeText) || !strings.Contains(got, "token=<redacted>") {
|
||||
t.Fatalf("semantic candidate excerpt missing from view: %q", got)
|
||||
}
|
||||
|
||||
messages := BuildPrompt(f)
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages len = %d, want 2", len(messages))
|
||||
}
|
||||
if !strings.Contains(messages[1].Content, scopeText) || !strings.Contains(messages[1].Content, "redacted") {
|
||||
t.Fatalf("prompt missing sanitized public content excerpt: %s", messages[1].Content)
|
||||
}
|
||||
if strings.Contains(messages[1].Content, "real-"+"secret-value") {
|
||||
t.Fatalf("prompt leaked raw sensitive value %q", messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewExcludesPublicContentWarningsWithoutSemanticCandidate(t *testing.T) {
|
||||
f := facts.Facts{
|
||||
SchemaVersion: 1,
|
||||
PublicContent: []facts.PublicContentFact{{
|
||||
Rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
Action: report.ActionWarning,
|
||||
File: "docs/network.md",
|
||||
Line: 1,
|
||||
Source: "file",
|
||||
Excerpt: "192.168." + "0.10",
|
||||
Message: "public contribution contains a pri" + "vate-network IP address",
|
||||
}},
|
||||
}
|
||||
|
||||
view := BuildInputView(f)
|
||||
if len(view.PublicContentLeakage) != 0 {
|
||||
t.Fatalf("warning-only public content should not enter semantic view: %#v", view.PublicContentLeakage)
|
||||
}
|
||||
if len(view.Diagnostics) != 0 {
|
||||
t.Fatalf("warning-only public content should not add diagnostics: %#v", view.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputViewSummarizesBroadChangedCommandSurface(t *testing.T) {
|
||||
f := broadChangedFacts(434, 44)
|
||||
|
||||
|
||||
@@ -138,6 +138,10 @@ func parseWaiver(parts []string, lineNo int) (Waiver, error) {
|
||||
if item.SourceFile == "" || item.Line == 0 {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires source_file and line", waiverPath, lineNo, item.FactKind)
|
||||
}
|
||||
case "public_content":
|
||||
if item.SourceFile == "" || item.Line == 0 || item.CommandPath != "" {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: public_content waiver requires source_file and line only", waiverPath, lineNo)
|
||||
}
|
||||
case "command", "output":
|
||||
if item.CommandPath == "" {
|
||||
return Waiver{}, fmt.Errorf("%s:%d: %s waiver requires command_path", waiverPath, lineNo, item.FactKind)
|
||||
|
||||
@@ -21,24 +21,27 @@ func TestLoadWaivers(t *testing.T) {
|
||||
|
||||
writeSemanticFile(t, repo, "waivers.txt", "# waiver_id\tcategory\tfact_kind\tsource_file\tline\tcommand_path\towner\treason\tadded_at\texpires_at\n"+
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/SKILL.md\t30\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n")
|
||||
"wiki-move-202606\tskill_quality\tskill\tskills/lark-wiki/references/move.md\t12\t\twiki-owner\tmigration\t2026-06-08\t2026-07-15\n"+
|
||||
"public-doc-202606\tpublic_content_leakage\tpublic_content\tdocs/public.md\t4\t\tsecurity-owner\treviewed false positive\t2026-06-08\t2026-07-15\n")
|
||||
w, diags, err = LoadWaivers(repo, now)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWaivers() error = %v", err)
|
||||
}
|
||||
if len(diags) != 0 || len(w.Items) != 2 {
|
||||
if len(diags) != 0 || len(w.Items) != 3 {
|
||||
t.Fatalf("LoadWaivers() = %#v %#v", w, diags)
|
||||
}
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"bad columns": "one\ttoo-few\n",
|
||||
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
|
||||
"bad columns": "one\ttoo-few\n",
|
||||
"bad id": "BAD\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad fact kind": "id1\terror_hint\tskill_quality\tcmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing owner": "id1\terror_hint\terror\tcmd/root.go\t1\t\t\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing line": "id1\terror_hint\terror\tcmd/root.go\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"missing command": "id1\tdefault_output\toutput\t\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"public content missing line": "id1\tpublic_content_leakage\tpublic_content\tdocs/public.md\t\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"public content command selector": "id1\tpublic_content_leakage\tpublic_content\t\t\tcmd/foo\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad source path": "id1\terror_hint\terror\t../cmd/root.go\t1\t\to\tr\t2026-06-08\t2026-07-15\n",
|
||||
"bad date format": "id1\terror_hint\terror\tcmd/root.go\t1\t\to\tr\t20260608\t2026-07-15\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
writeSemanticFile(t, repo, "waivers.txt", body)
|
||||
|
||||
@@ -5609,6 +5609,21 @@
|
||||
"final_score": "80.0587",
|
||||
"recommend": "false"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.nickname:read",
|
||||
"final_score": "88.0587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.nickname:write",
|
||||
"final_score": "79.5982",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:write",
|
||||
"final_score": "83.6587",
|
||||
"recommend": "true"
|
||||
},
|
||||
{
|
||||
"scope_name": "im:chat.user_setting:read",
|
||||
"final_score": "88.0587",
|
||||
|
||||
@@ -472,6 +472,18 @@ func TestConvert_EnumDescriptions(t *testing.T) {
|
||||
if bare.EnumDescriptions != nil {
|
||||
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
|
||||
}
|
||||
|
||||
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
|
||||
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
}})
|
||||
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
|
||||
t.Errorf("both Enum = %v", both.Enum)
|
||||
}
|
||||
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
|
||||
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.55",
|
||||
"version": "1.0.58",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -45,6 +45,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
|
||||
repo: context.repo.repo,
|
||||
pull_number: target.pr,
|
||||
});
|
||||
if (pr.state !== "open") {
|
||||
core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.head.sha !== target.headSha) {
|
||||
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
|
||||
return false;
|
||||
|
||||
@@ -152,6 +152,25 @@ describe("ci-quality-summary-publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish a summary when the PR closes before comment creation", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
await publish({
|
||||
github: fakeGithub(calls, {
|
||||
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
|
||||
pullResponses: [
|
||||
currentPullResponse(),
|
||||
currentPullResponse({ state: "closed" }),
|
||||
],
|
||||
}),
|
||||
context: workflowRunContext({ conclusion: "failure" }),
|
||||
core: silentCore(calls),
|
||||
});
|
||||
|
||||
assert.equal(calls.comments.length, 0);
|
||||
assert.match(calls.notices.join("\n"), /PR is no longer open/);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
await publish({
|
||||
@@ -338,6 +357,7 @@ function fakeGithub(calls, options = {}) {
|
||||
function currentPullResponse(overrides = {}) {
|
||||
return {
|
||||
data: {
|
||||
state: overrides.state || "open",
|
||||
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
|
||||
base: {
|
||||
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,
|
||||
|
||||
@@ -5,26 +5,42 @@
|
||||
set -euo pipefail
|
||||
|
||||
workflow=".github/workflows/ci.yml"
|
||||
job_section() {
|
||||
local job="$1"
|
||||
awk -v job="$job" '
|
||||
$0 == " " job ":" { in_job = 1; print; next }
|
||||
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
in_job { print }
|
||||
' "$workflow"
|
||||
}
|
||||
workflow_permissions="$(awk '
|
||||
/^permissions:/ { in_permissions = 1; print; next }
|
||||
in_permissions && /^[^[:space:]]/ { exit }
|
||||
in_permissions { print }
|
||||
' "$workflow")"
|
||||
fast_gate_section="$(job_section fast-gate)"
|
||||
unit_test_section="$(job_section unit-test)"
|
||||
lint_section="$(awk '
|
||||
/^ lint:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ deterministic-gate:/ { exit }
|
||||
/^ script-test:/ { exit }
|
||||
' "$workflow")"
|
||||
script_test_section="$(job_section script-test)"
|
||||
deterministic_section="$(awk '
|
||||
/^ deterministic-gate:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ coverage:/ { exit }
|
||||
' "$workflow")"
|
||||
coverage_job_section="$(job_section coverage)"
|
||||
deadcode_section="$(job_section deadcode)"
|
||||
dry_run_section="$(job_section e2e-dry-run)"
|
||||
section="$(awk '
|
||||
/^ e2e-live:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
/^ security:/ { exit }
|
||||
' "$workflow")"
|
||||
security_section="$(job_section security)"
|
||||
license_header_section="$(job_section license-header)"
|
||||
results_section="$(awk '
|
||||
/^ results:/ { in_job = 1 }
|
||||
in_job { print }
|
||||
@@ -98,13 +114,94 @@ if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should write PR title/body metadata before quality-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
|
||||
echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "script-test:" <<<"$script_test_section"; then
|
||||
echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "make script-test" <<<"$script_test_section"; then
|
||||
echo "script-test job should invoke make script-test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
|
||||
echo "script-test job should install Node for JavaScript workflow tests"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
|
||||
echo "script-test must not reference secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "metadata-gate:" "$workflow"; then
|
||||
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
|
||||
echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for full_job in \
|
||||
"$unit_test_section" \
|
||||
"$lint_section" \
|
||||
"$script_test_section" \
|
||||
"$deterministic_section" \
|
||||
"$coverage_job_section" \
|
||||
"$dry_run_section" \
|
||||
"$security_section"; do
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
|
||||
echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for pull_request_job in "$deadcode_section" "$license_header_section"; do
|
||||
if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
|
||||
echo "pull_request-only CI jobs must run on edited events"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate must not reference secrets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should pass public content metadata into make quality-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should pass the pull request branch into public content metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
|
||||
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "needs: [unit-test, lint, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for deterministic-gate"
|
||||
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
|
||||
echo "E2E jobs should wait for script-test and deterministic-gate"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "script-test" <<<"$results_section"; then
|
||||
echo "results job should include script-test"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -210,6 +307,11 @@ if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_outp
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
|
||||
echo "quality-gate check should consume public content metadata"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
|
||||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
|
||||
echo "quality-gate check should consume both exported command snapshots"
|
||||
|
||||
@@ -89,7 +89,7 @@ def main():
|
||||
count = len(data.get("services", []))
|
||||
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
|
||||
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ function inlineCode(value) {
|
||||
}
|
||||
|
||||
function parseEvidenceRef(ref) {
|
||||
const match = /^facts\.(commands|skills|errors|outputs)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -230,6 +230,20 @@ function evidenceLocation(facts, ref) {
|
||||
return { kind: parsed.kind, command: item.path, label: item.path };
|
||||
}
|
||||
return null;
|
||||
case "public_content":
|
||||
if (item.file && Number.isInteger(item.line) && item.line > 0) {
|
||||
const label = `${item.file}:${item.line}`;
|
||||
if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
|
||||
return { kind: parsed.kind, label };
|
||||
}
|
||||
return {
|
||||
kind: parsed.kind,
|
||||
path: item.file,
|
||||
line: item.line,
|
||||
label,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -845,6 +859,10 @@ async function publishTargetStillCurrent(github, context, core, target, phase =
|
||||
repo: context.repo.repo,
|
||||
pull_number: target.pr,
|
||||
});
|
||||
if (pr.state !== "open") {
|
||||
core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
|
||||
return false;
|
||||
}
|
||||
if (pr.head.sha !== target.headSha) {
|
||||
core.notice(`semantic review skipped: PR head changed before ${phase}`);
|
||||
return false;
|
||||
|
||||
@@ -202,6 +202,100 @@ describe("semantic-review-publish", () => {
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.errors[0]"] }, facts, changedLineIndex), null);
|
||||
});
|
||||
|
||||
it("maps public content evidence to changed files but not virtual metadata", () => {
|
||||
const restrictedScope = "pri" + "vate";
|
||||
const facts = {
|
||||
public_content: [
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "docs/public-roadmap.md",
|
||||
line: 4,
|
||||
source: "file",
|
||||
},
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "pull_request_metadata",
|
||||
line: 1,
|
||||
source: "metadata",
|
||||
},
|
||||
{
|
||||
rule: "public_content_automation_branch",
|
||||
action: "WARNING",
|
||||
file: "branch",
|
||||
line: 1,
|
||||
source: "branch",
|
||||
},
|
||||
{
|
||||
rule: "public_content_change_id_trailer",
|
||||
action: "REJECT",
|
||||
file: "commit:1234abc",
|
||||
line: 3,
|
||||
source: "commit",
|
||||
},
|
||||
],
|
||||
};
|
||||
const changedLineIndex = buildChangedLineIndex([{
|
||||
filename: "docs/public-roadmap.md",
|
||||
patch: [
|
||||
"@@ -3,2 +3,3 @@",
|
||||
" context",
|
||||
"+Specific " + restrictedScope + " roadmap detail",
|
||||
].join("\n"),
|
||||
}]);
|
||||
|
||||
assert.deepEqual(
|
||||
selectInlineTarget({ evidence: ["facts.public_content[0]"] }, facts, changedLineIndex),
|
||||
{ path: "docs/public-roadmap.md", line: 4 },
|
||||
);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[1]"] }, facts, changedLineIndex), null);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[2]"] }, facts, changedLineIndex), null);
|
||||
assert.equal(selectInlineTarget({ evidence: ["facts.public_content[3]"] }, facts, changedLineIndex), null);
|
||||
|
||||
const markdown = buildSummaryMarkdown({
|
||||
block_mode: true,
|
||||
blockers: [{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[1]"],
|
||||
fingerprint: "public-content-metadata",
|
||||
message: "PR metadata contains " + restrictedScope + " rollout detail",
|
||||
suggested_action: "Move " + restrictedScope + " detail to an internal channel.",
|
||||
}],
|
||||
warnings: [],
|
||||
}, facts);
|
||||
assert.match(markdown, /pull_request_metadata:1/);
|
||||
|
||||
const virtualMarkdown = buildSummaryMarkdown({
|
||||
block_mode: true,
|
||||
blockers: [
|
||||
{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[2]"],
|
||||
fingerprint: "public-content-branch",
|
||||
message: "Branch name looks automation-owned.",
|
||||
suggested_action: "Use a maintainer-owned public branch name.",
|
||||
},
|
||||
{
|
||||
category: "public_content_leakage",
|
||||
severity: "major",
|
||||
review_action: "must_fix",
|
||||
evidence: ["facts.public_content[3]"],
|
||||
fingerprint: "public-content-commit",
|
||||
message: "Commit trailer contains " + restrictedScope + " review metadata.",
|
||||
suggested_action: "Remove " + restrictedScope + " review metadata from commits.",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
}, facts);
|
||||
assert.match(virtualMarkdown, /branch:1/);
|
||||
assert.match(virtualMarkdown, /commit:1234abc:3/);
|
||||
});
|
||||
|
||||
it("builds finding markers from stable fingerprints and evidence identity", () => {
|
||||
const factsA = {
|
||||
skills: [{
|
||||
@@ -615,6 +709,35 @@ describe("semantic-review-publish", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("skips publishing when the PR closes after verification", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
fs.writeFileSync("decision.json", JSON.stringify({
|
||||
block_mode: true,
|
||||
blockers: [],
|
||||
warnings: [],
|
||||
}), "utf8");
|
||||
|
||||
await publish({
|
||||
github: fakeGithub(calls, {
|
||||
currentPullRequest: {
|
||||
state: "closed",
|
||||
head: { sha: "0123456789abcdef0123456789abcdef01234567" },
|
||||
base: {
|
||||
sha: "fedcba9876543210fedcba9876543210fedcba98",
|
||||
repo: { id: 123 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
context: workflowRunContext(),
|
||||
core: silentCore(calls),
|
||||
});
|
||||
|
||||
assert.equal(calls.checks.length, 0);
|
||||
assert.equal(calls.comments.length, 0);
|
||||
assert.match(calls.notices[0], /PR is no longer open before publishing/);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects publishing when the PR base repo changed after verification", async () => {
|
||||
await withPublishTempDir(async ({ calls }) => {
|
||||
fs.writeFileSync("decision.json", JSON.stringify({
|
||||
@@ -2223,8 +2346,8 @@ function fakeGithub(calls, options = {}) {
|
||||
},
|
||||
},
|
||||
pulls: {
|
||||
get: async () => ({
|
||||
data: Array.isArray(options.currentPullRequests)
|
||||
get: async () => {
|
||||
const pull = Array.isArray(options.currentPullRequests)
|
||||
? options.currentPullRequests[Math.min(pullGetCount++, options.currentPullRequests.length - 1)]
|
||||
: options.currentPullRequest || {
|
||||
head: { sha: process.env.SEMANTIC_REVIEW_HEAD_SHA },
|
||||
@@ -2232,8 +2355,9 @@ function fakeGithub(calls, options = {}) {
|
||||
sha: process.env.SEMANTIC_REVIEW_BASE_SHA,
|
||||
repo: { id: 123 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { data: { state: "open", ...pull } };
|
||||
},
|
||||
listFiles() {},
|
||||
listReviewComments() {},
|
||||
createReviewComment: async (args) => {
|
||||
|
||||
@@ -229,6 +229,36 @@ function requireSafePath(value, path) {
|
||||
return file;
|
||||
}
|
||||
|
||||
function requirePublicContentFile(value, path) {
|
||||
const file = requireString(value, path);
|
||||
if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
|
||||
return file;
|
||||
}
|
||||
if (file.startsWith("commit:")) {
|
||||
throw new Error(`facts JSON ${path} must be a valid public content location`);
|
||||
}
|
||||
requireSafePath(file, path);
|
||||
if (
|
||||
file === "" ||
|
||||
file === "." ||
|
||||
file.startsWith("./") ||
|
||||
file.includes("\\") ||
|
||||
file.includes("\0") ||
|
||||
file.split("/").includes(".git") ||
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
|
||||
) {
|
||||
throw new Error(`facts JSON ${path} must be a repository-relative path`);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function requirePositiveLine(value, path) {
|
||||
requireLine(value, path);
|
||||
if (value === 0) {
|
||||
throw new Error(`facts JSON ${path} must be a positive line number`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArray(value, path, { optional = false } = {}) {
|
||||
if (value === undefined || value === null) {
|
||||
if (optional) {
|
||||
@@ -421,6 +451,20 @@ function verifyFactsJSON(data) {
|
||||
for (const [i, value] of requireArray(facts, "examples").entries()) {
|
||||
verifyCommandExample(value, `examples[${i}]`);
|
||||
}
|
||||
for (const [i, value] of requireArray(facts, "public_content").entries()) {
|
||||
const item = requireObject(value, `public_content[${i}]`);
|
||||
requireString(item.rule, `public_content[${i}].rule`);
|
||||
const action = requireString(item.action, `public_content[${i}].action`);
|
||||
if (!VALID_ACTIONS.has(action)) {
|
||||
throw new Error(`facts JSON public_content[${i}].action is invalid`);
|
||||
}
|
||||
requirePublicContentFile(item.file, `public_content[${i}].file`);
|
||||
requirePositiveLine(item.line, `public_content[${i}].line`);
|
||||
requireString(item.source, `public_content[${i}].source`, { optional: true });
|
||||
requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
|
||||
requireString(item.message, `public_content[${i}].message`, { optional: true });
|
||||
requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
|
||||
}
|
||||
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
|
||||
const item = requireObject(value, `diagnostics[${i}]`);
|
||||
requireString(item.rule, `diagnostics[${i}].rule`);
|
||||
|
||||
@@ -67,7 +67,43 @@ describe("verifyZipEntries", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
|
||||
const zipPath = path.join(dir, "facts.zip");
|
||||
const outPath = path.join(dir, "facts.json");
|
||||
const facts = Buffer.from('{"schema_version":1}\n');
|
||||
const restrictedScope = "pri" + "vate";
|
||||
const facts = Buffer.from(JSON.stringify({
|
||||
schema_version: 1,
|
||||
public_content: [
|
||||
{
|
||||
rule: "public_content_semantic_candidate",
|
||||
action: "WARNING",
|
||||
file: "pull_request_metadata",
|
||||
line: 1,
|
||||
source: "metadata",
|
||||
excerpt: "public release notes mention an internal rollout plan",
|
||||
message: "public contribution may contain sensitive implementation detail",
|
||||
suggestion: "move internal detail to " + restrictedScope + " discussion",
|
||||
},
|
||||
{
|
||||
rule: "public_content_change_id_trailer",
|
||||
action: "REJECT",
|
||||
file: "commit:1234abc",
|
||||
line: 3,
|
||||
source: "commit",
|
||||
},
|
||||
{
|
||||
rule: "public_content_automation_branch",
|
||||
action: "WARNING",
|
||||
file: "branch",
|
||||
line: 1,
|
||||
source: "branch",
|
||||
},
|
||||
{
|
||||
rule: "public_content_" + "pri" + "vate_ipv4",
|
||||
action: "WARNING",
|
||||
file: "docs/public-network.md",
|
||||
line: 7,
|
||||
source: "file",
|
||||
},
|
||||
],
|
||||
}) + "\n");
|
||||
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
|
||||
fs.writeFileSync(zipPath, zip);
|
||||
|
||||
@@ -103,6 +139,19 @@ describe("verifyZipEntries", () => {
|
||||
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
|
||||
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
|
||||
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
|
||||
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
|
||||
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
|
||||
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
|
||||
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
|
||||
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
|
||||
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
|
||||
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
|
||||
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
|
||||
]) {
|
||||
|
||||
@@ -179,8 +179,15 @@ fi
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
|
||||
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"
|
||||
|
||||
@@ -198,8 +205,9 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
|
||||
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
|
||||
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
|
||||
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
|
||||
@@ -208,10 +216,15 @@ require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail cl
|
||||
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
|
||||
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
|
||||
require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
|
||||
require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
|
||||
require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
|
||||
require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
|
||||
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
|
||||
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"
|
||||
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
|
||||
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
|
||||
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
|
||||
@@ -256,6 +269,7 @@ require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiv
|
||||
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
|
||||
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
|
||||
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
|
||||
require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
|
||||
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
|
||||
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -84,6 +85,9 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
dry.Set("oversize_html", hits)
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
@@ -140,18 +144,22 @@ type appsHTMLPublishSpec struct {
|
||||
// per-environment .env.* files for every stage).
|
||||
const maxSensitiveListInError = 5
|
||||
|
||||
// truncatedJoin joins items with ", ", capping at max entries and appending
|
||||
// "(and N more)" for the remainder, so an inline error list stays readable when
|
||||
// a payload has many hits.
|
||||
func truncatedJoin(items []string, max int) string {
|
||||
if len(items) <= max {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
|
||||
}
|
||||
|
||||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||||
// contains credential files and --allow-sensitive was not set.
|
||||
func sensitiveCandidatesError(hits []string) error {
|
||||
var sample string
|
||||
if len(hits) <= maxSensitiveListInError {
|
||||
sample = strings.Join(hits, ", ")
|
||||
} else {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
"--path contains %d credential file(s) that should not be published: %s",
|
||||
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
@@ -168,6 +176,30 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html(大小写不敏感)且单个 Size 超过
|
||||
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
|
||||
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
|
||||
var hits []string
|
||||
for _, c := range candidates {
|
||||
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
|
||||
hits = append(hits, c.RelPath)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
|
||||
func oversizeHTMLFilesError(hits []string) error {
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
|
||||
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
|
||||
}
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
@@ -190,6 +222,9 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
|
||||
@@ -503,3 +503,82 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
cands := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", Size: 50},
|
||||
{RelPath: "big.html", Size: 4096},
|
||||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||||
}
|
||||
hits := oversizeHTMLFiles(cands)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h == "huge.png" || h == "index.html" {
|
||||
t.Fatalf("unexpected hit %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,14 @@ var AppsInit = common.Shortcut{
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
dry.Set("already_initialized", true)
|
||||
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
|
||||
if existing != "" {
|
||||
dry.Set("app_id_mismatch", existing)
|
||||
}
|
||||
dry.Set("dir_error", e.Error())
|
||||
} else {
|
||||
dry.Set("already_initialized", true)
|
||||
}
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
@@ -199,6 +206,61 @@ func isAlreadyInitialized(dir string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id,用于判断目标目录是否同一个妙搭应用。
|
||||
// 返回 (appID, isSparkProject, err):
|
||||
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
|
||||
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
|
||||
// - 解析成功 → (trim 后的 app_id, true, nil)(app_id 缺失/为空时为 "")
|
||||
func readMetaAppID(dir string) (string, bool, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m struct {
|
||||
AppID string `json:"app_id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return strings.TrimSpace(m.AppID), true, nil
|
||||
}
|
||||
|
||||
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
|
||||
// - 不是妙搭工程(无 meta.json) → nil(交给 ensureEmptyDir 判空/非空)
|
||||
// - 是妙搭工程且 app_id 与 appID 一致 → nil(走已初始化短路,复用本地代码)
|
||||
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
|
||||
// - meta.json 损坏/不可读,无法确认 → 报错(fail closed),提示换目录
|
||||
//
|
||||
// 返回值 existing 是目录里已存在的 app_id(仅"已是另一个 app"的拒绝场景非空),供调用方在
|
||||
// dry-run 里回填 app_id_mismatch,避免二次读 meta.json。
|
||||
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
|
||||
existing, isSpark, readErr := readMetaAppID(dir)
|
||||
if readErr != nil {
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
|
||||
WithCause(readErr)
|
||||
}
|
||||
if !isSpark || existing == appID {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == "" {
|
||||
// meta 存在但缺 app_id:更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("remove the directory and re-run +init, or choose a different --dir")
|
||||
}
|
||||
return existing, appsValidationParamError("--dir",
|
||||
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
|
||||
dir, existing, appID).
|
||||
WithHint("choose a different --dir (or cd into the matching project) before running +init")
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
@@ -378,6 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
|
||||
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
@@ -394,6 +394,40 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app_id must error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") {
|
||||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||||
@@ -1468,6 +1502,125 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetaAppID(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 不存在 meta.json → ("", false, nil)
|
||||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||||
}
|
||||
// 存在且有 app_id → (app_id, true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但 app_id 空 → ("", true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||||
t.Fatalf("no meta should pass: %v", err)
|
||||
}
|
||||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||||
}
|
||||
|
||||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||||
if errMismatch == nil {
|
||||
t.Fatal("different app should error")
|
||||
}
|
||||
if existing != "app_other" {
|
||||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(errMismatch, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||||
}
|
||||
if ve.Param != "--dir" {
|
||||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "different --dir") {
|
||||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||||
}
|
||||
|
||||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||||
if errEmpty == nil {
|
||||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||||
}
|
||||
if emptyExisting != "" {
|
||||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||||
}
|
||||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||||
}
|
||||
if strings.Contains(pEmpty.Message, "different app") {
|
||||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||||
}
|
||||
|
||||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||||
if errBad == nil {
|
||||
t.Fatal("corrupted meta should fail closed")
|
||||
}
|
||||
if badExisting != "" {
|
||||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||||
}
|
||||
pBad := requireAppsValidationProblem(t, errBad)
|
||||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||||
}
|
||||
var veBad *errs.ValidationError
|
||||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -32,6 +33,7 @@ import (
|
||||
)
|
||||
|
||||
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
|
||||
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
|
||||
|
||||
// gitCredentialIssueHint is the actionable next-step attached to a failed
|
||||
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
|
||||
@@ -302,7 +304,12 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
ctx = contextWithGitCredentialHelperShortcut(ctx)
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
@@ -314,6 +321,13 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
|
||||
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
|
||||
return ctx
|
||||
}
|
||||
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
|
||||
}
|
||||
|
||||
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
|
||||
if f == nil || f.IOStreams == nil {
|
||||
return nil
|
||||
|
||||
@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
|
||||
func TestFactoryIssuerBranches(t *testing.T) {
|
||||
factory, _, reg := newAppsExecuteFactory(t)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Unix()
|
||||
reg.Register(&httpmock.Stub{
|
||||
issueStub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
|
||||
Body: map[string]interface{}{
|
||||
@@ -836,7 +836,8 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
"StatusCode": 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(issueStub)
|
||||
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("factory issuer returned error: %v", err)
|
||||
@@ -844,6 +845,12 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
if issued.PAT != "pat-token" {
|
||||
t.Fatalf("PAT = %q", issued.PAT)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
|
||||
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
|
||||
}
|
||||
|
||||
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
|
||||
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
|
||||
@@ -880,6 +887,20 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
|
||||
got := contextWithGitCredentialHelperShortcut(ctx)
|
||||
|
||||
name, ok := cmdutil.ShortcutNameFromContext(got)
|
||||
if !ok || name != "apps:+git-credential-init" {
|
||||
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
|
||||
}
|
||||
executionID, ok := cmdutil.ExecutionIdFromContext(got)
|
||||
if !ok || executionID != "exec-existing" {
|
||||
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
|
||||
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))
|
||||
|
||||
545
shortcuts/base/base_resolve.go
Normal file
545
shortcuts/base/base_resolve.go
Normal file
@@ -0,0 +1,545 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
|
||||
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
|
||||
nextStepRecordList = "use +record-list to list records in the resolved table"
|
||||
titleResolveQueryMaxLen = 30
|
||||
)
|
||||
|
||||
var BaseURLResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+url-resolve",
|
||||
Description: "Resolve a Base-related URL into Base coordinates",
|
||||
Risk: "read",
|
||||
Scopes: []string{},
|
||||
ConditionalScopes: []string{
|
||||
"base:field:read",
|
||||
"base:record:read",
|
||||
"wiki:node:retrieve",
|
||||
},
|
||||
AuthTypes: authTypes(),
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
|
||||
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readURLResolveInput(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "wiki_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
|
||||
case "record_share_url":
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
|
||||
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
|
||||
default:
|
||||
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseURLResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
var BaseTitleResolve = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+title-resolve",
|
||||
Description: "Resolve a Base title or keyword through Drive search",
|
||||
Risk: "read",
|
||||
Scopes: []string{"search:docs:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
|
||||
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
|
||||
},
|
||||
Tips: []string{
|
||||
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
|
||||
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readTitleResolveQuery(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/search/v2/doc_wiki/search").
|
||||
Body(buildTitleResolveSearchBody(query))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return executeBaseTitleResolve(runtime)
|
||||
},
|
||||
}
|
||||
|
||||
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
|
||||
urlValue := strings.TrimSpace(runtime.Str("url"))
|
||||
queryValue := strings.TrimSpace(runtime.Str("query"))
|
||||
if urlValue != "" && queryValue != "" {
|
||||
return "", baseFlagErrorf("--url and --query are mutually exclusive")
|
||||
}
|
||||
value := urlValue
|
||||
if value == "" {
|
||||
value = queryValue
|
||||
}
|
||||
if value == "" {
|
||||
return "", baseFlagErrorf("specify --url")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
|
||||
values := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"title", strings.TrimSpace(runtime.Str("title"))},
|
||||
{"query", strings.TrimSpace(runtime.Str("query"))},
|
||||
{"url", strings.TrimSpace(runtime.Str("url"))},
|
||||
}
|
||||
var pickedName, pickedValue string
|
||||
for _, v := range values {
|
||||
if v.value == "" {
|
||||
continue
|
||||
}
|
||||
if pickedValue != "" {
|
||||
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
|
||||
}
|
||||
pickedName = v.name
|
||||
pickedValue = v.value
|
||||
}
|
||||
if pickedValue == "" {
|
||||
return "", baseFlagErrorf("specify --title")
|
||||
}
|
||||
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
|
||||
return "", resolveValidationError(
|
||||
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
|
||||
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
}
|
||||
return pickedValue, nil
|
||||
}
|
||||
|
||||
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
|
||||
raw, err := readURLResolveInput(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := parseResolveURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch classifyBaseURL(parsed) {
|
||||
case "base_url":
|
||||
out := resolveBaseURL(parsed)
|
||||
enrichBaseResolveHint(runtime, out)
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "wiki_url":
|
||||
out, err := resolveWikiBaseURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "record_share_url":
|
||||
out, err := resolveRecordShareURL(runtime, parsed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
case "form_share_url":
|
||||
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
|
||||
return nil
|
||||
case "view_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "dashboard_share_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "workspace_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
case "add_record_url":
|
||||
return resolveValidationError(
|
||||
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
|
||||
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
|
||||
)
|
||||
default:
|
||||
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
|
||||
}
|
||||
}
|
||||
|
||||
func parseResolveURL(raw string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func classifyBaseURL(u *url.URL) string {
|
||||
path := normalizeResolvePath(u.Path)
|
||||
switch {
|
||||
case pathSegmentExists(path, "/base/workspace/"):
|
||||
return "workspace_url"
|
||||
case pathSegmentExists(path, "/base/add/"):
|
||||
return "add_record_url"
|
||||
case pathSegmentExists(path, "/base/"):
|
||||
return "base_url"
|
||||
case pathSegmentExists(path, "/wiki/"):
|
||||
return "wiki_url"
|
||||
case pathSegmentExists(path, "/record/"):
|
||||
return "record_share_url"
|
||||
case pathSegmentExists(path, "/share/base/form/"):
|
||||
return "form_share_url"
|
||||
case pathSegmentExists(path, "/share/base/view/"):
|
||||
return "view_share_url"
|
||||
case pathSegmentExists(path, "/share/base/dashboard/"):
|
||||
return "dashboard_share_url"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBaseURL(u *url.URL) map[string]interface{} {
|
||||
query := u.Query()
|
||||
out := map[string]interface{}{
|
||||
"input_type": "base_url",
|
||||
"resource_type": "bitable",
|
||||
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
|
||||
}
|
||||
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
|
||||
out["table_id"] = tableID
|
||||
}
|
||||
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
|
||||
out["view_id"] = viewID
|
||||
}
|
||||
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
|
||||
out["record_id"] = recordID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
token := firstPathSegmentAfter(u.Path, "/wiki/")
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := common.GetMap(data, "node")
|
||||
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
|
||||
if objType != "bitable" {
|
||||
return nil, resolveValidationError(
|
||||
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
|
||||
"Use the corresponding skill for that resource, or provide a Base URL.",
|
||||
)
|
||||
}
|
||||
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
|
||||
if baseToken == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"input_type": "wiki_url",
|
||||
"resource_type": "bitable",
|
||||
"wiki_node_token": token,
|
||||
"base_token": baseToken,
|
||||
"title": common.GetString(node, "title"),
|
||||
"hint": resolveHint("", nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
|
||||
shareToken := firstPathSegmentAfter(u.Path, "/record/")
|
||||
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"input_type": "record_share_url",
|
||||
"resource_type": "bitable",
|
||||
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
|
||||
"base_token": common.GetString(data, "base_token"),
|
||||
"table_id": common.GetString(data, "table_id"),
|
||||
"record_id": common.GetString(data, "record_id"),
|
||||
}
|
||||
enrichRecordShareResolveHint(runtime, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func resolveFormShareURL(u *url.URL) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"input_type": "form_share_url",
|
||||
"resource_type": "bitable_form",
|
||||
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
|
||||
query, err := readTitleResolveQuery(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
return resolveValidationError(
|
||||
"No Base matched this title or keyword.",
|
||||
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
|
||||
)
|
||||
case 1:
|
||||
out := map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"title": candidates[0]["title"],
|
||||
"base_token": candidates[0]["base_token"],
|
||||
"url": candidates[0]["url"],
|
||||
"owner_name": candidates[0]["owner_name"],
|
||||
"update_time": candidates[0]["update_time"],
|
||||
"hint": resolveHint("", nil),
|
||||
}
|
||||
runtime.OutFormat(out, nil, nil)
|
||||
return nil
|
||||
default:
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"input_type": "title_query",
|
||||
"resource_type": "bitable",
|
||||
"candidates": candidates,
|
||||
"hint": map[string]interface{}{
|
||||
"next_step": baseTitleResolveHint,
|
||||
},
|
||||
}, nil, nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
if baseToken == "" || tableID == "" {
|
||||
out["hint"] = resolveHint("", nil)
|
||||
return
|
||||
}
|
||||
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
|
||||
if err != nil {
|
||||
out["hint"] = resolveHint(tableID, nil)
|
||||
return
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
|
||||
}
|
||||
|
||||
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
|
||||
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
|
||||
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
|
||||
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
|
||||
hint := map[string]interface{}{}
|
||||
if baseToken != "" && tableID != "" && recordID != "" {
|
||||
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
|
||||
hint["record_data"] = formatResolvedRecordData(record)
|
||||
}
|
||||
}
|
||||
if baseToken != "" && tableID != "" {
|
||||
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
|
||||
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
|
||||
}
|
||||
}
|
||||
out["hint"] = resolveHint(tableID, hint)
|
||||
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
|
||||
body := map[string]interface{}{"record_id_list": []string{recordID}}
|
||||
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
|
||||
return handleBaseAPIResult(result, err, "batch get records")
|
||||
}
|
||||
|
||||
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
|
||||
fieldIDs := common.GetSlice(record, "field_id_list")
|
||||
fieldNames := common.GetSlice(record, "fields")
|
||||
rows := common.GetSlice(record, "data")
|
||||
|
||||
data := map[string]interface{}{}
|
||||
if len(rows) > 0 {
|
||||
if values, ok := rows[0].([]interface{}); ok {
|
||||
for i, value := range values {
|
||||
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
|
||||
if index < len(fieldIDs) {
|
||||
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
|
||||
return fieldID
|
||||
}
|
||||
}
|
||||
if index < len(fieldNames) {
|
||||
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
|
||||
return fieldName
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("field_%d", index+1)
|
||||
}
|
||||
|
||||
func recordShareNextStep(baseToken, tableID, recordID string) string {
|
||||
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
|
||||
}
|
||||
|
||||
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
|
||||
hint := map[string]interface{}{}
|
||||
for key, value := range extra {
|
||||
hint[key] = value
|
||||
}
|
||||
if strings.TrimSpace(tableID) != "" {
|
||||
hint["next_step"] = nextStepRecordList
|
||||
} else {
|
||||
hint["next_step"] = nextStepBaseBlockList
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
func buildTitleResolveSearchBody(query string) map[string]interface{} {
|
||||
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
|
||||
return map[string]interface{}{
|
||||
"query": query,
|
||||
"page_size": 5,
|
||||
"doc_filter": filter,
|
||||
"wiki_filter": filter,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
|
||||
candidates := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, _ := item.(map[string]interface{})
|
||||
meta, _ := row["result_meta"].(map[string]interface{})
|
||||
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
|
||||
continue
|
||||
}
|
||||
token := strings.TrimSpace(common.GetString(meta, "token"))
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(common.GetString(row, "title"))
|
||||
}
|
||||
candidates = append(candidates, map[string]interface{}{
|
||||
"title": title,
|
||||
"base_token": token,
|
||||
"url": common.GetString(meta, "url"),
|
||||
"owner_name": common.GetString(meta, "owner_name"),
|
||||
"update_time": common.GetString(meta, "update_time_iso"),
|
||||
})
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
|
||||
|
||||
func stripSearchHighlight(s string) string {
|
||||
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
func resolveValidationError(message, hint string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
|
||||
}
|
||||
|
||||
func normalizeResolvePath(path string) string {
|
||||
if path == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func pathSegmentExists(path, prefix string) bool {
|
||||
return firstPathSegmentAfter(path, prefix) != ""
|
||||
}
|
||||
|
||||
func firstPathSegmentAfter(path, prefix string) string {
|
||||
path = normalizeResolvePath(path)
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
|
||||
rest = rest[:idx]
|
||||
}
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
func valueOrUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "an unknown resource type"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
454
shortcuts/base/base_resolve_test.go
Normal file
454
shortcuts/base/base_resolve_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestBaseURLResolveBaseURL(t *testing.T) {
|
||||
t.Run("with coordinates", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve",
|
||||
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
|
||||
"--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("missing Base coordinates: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("base only", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["table_id"]; ok {
|
||||
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepBaseBlockList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
if hint["next_step"] != nextStepRecordList {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveWikiURL(t *testing.T) {
|
||||
t.Run("bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "bitable",
|
||||
"obj_token": "bas123",
|
||||
"title": "Demo Base",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not Base") {
|
||||
t.Fatalf("err=%v, want non-Base validation error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseURLResolveRecordShareURL(t *testing.T) {
|
||||
t.Run("enriched", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
|
||||
reg.Register(fieldListStub("bas123", "tbl123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
recordData, _ := hint["record_data"].(map[string]interface{})
|
||||
fields, _ := hint["fields"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enrichment failure still returns meta", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
hint, _ := data["hint"].(map[string]interface{})
|
||||
nextStep, _ := hint["next_step"].(string)
|
||||
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
|
||||
t.Fatalf("unexpected hint: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["record_data"]; ok {
|
||||
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
if _, ok := hint["fields"]; ok {
|
||||
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_share_token": shareToken,
|
||||
"base_token": baseToken,
|
||||
"table_id": tableID,
|
||||
"record_id": recordID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveFormShareURL(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLResolveValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawURL string
|
||||
wantText string
|
||||
wantHint string
|
||||
}{
|
||||
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
|
||||
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
|
||||
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
|
||||
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
|
||||
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
|
||||
{"not url", "bas123", "only accepts full URLs", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", tc.rawURL, "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
|
||||
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("err=%v, want typed error with hint", err)
|
||||
}
|
||||
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
|
||||
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "original /base/{base_token}") {
|
||||
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseResolveInputXOR(t *testing.T) {
|
||||
t.Run("url resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
|
||||
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("title resolve", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v, want xor validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseResolveHelpFlags(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
shortcut string
|
||||
definition common.Shortcut
|
||||
primaryFlag string
|
||||
primaryDesc string
|
||||
aliasFlags []string
|
||||
}{
|
||||
{
|
||||
shortcut: "+url-resolve",
|
||||
definition: BaseURLResolve,
|
||||
primaryFlag: "url",
|
||||
primaryDesc: "Base/Wiki/record-share URL to resolve",
|
||||
aliasFlags: []string{"query"},
|
||||
},
|
||||
{
|
||||
shortcut: "+title-resolve",
|
||||
definition: BaseTitleResolve,
|
||||
primaryFlag: "title",
|
||||
primaryDesc: "Base title keyword",
|
||||
aliasFlags: []string{"query", "url"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.shortcut, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tc.definition.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
primary := cmd.Flags().Lookup(tc.primaryFlag)
|
||||
primaryUsage := ""
|
||||
if primary != nil {
|
||||
primaryUsage = primary.Usage
|
||||
}
|
||||
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
|
||||
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
|
||||
}
|
||||
for _, aliasFlag := range tc.aliasFlags {
|
||||
alias := cmd.Flags().Lookup(aliasFlag)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTitleResolve(t *testing.T) {
|
||||
t.Run("single result", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Sales <h>Pipeline</h>",
|
||||
"result_meta": map[string]interface{}{
|
||||
"doc_types": "BITABLE",
|
||||
"token": "bas123",
|
||||
"url": "https://example.larkoffice.com/base/bas123",
|
||||
"owner_name": "Alice",
|
||||
"update_time_iso": "2026-06-09T10:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "Pipeline", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple results and filter non bitable", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub([]interface{}{
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Doc hit",
|
||||
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>One</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"title_highlighted": "Base <h>Two</h>",
|
||||
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
|
||||
},
|
||||
}))
|
||||
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--url", "Base", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
candidates, _ := data["candidates"].([]interface{})
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates=%#v, want 2", data["candidates"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no results", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(titleResolveSearchStub(nil))
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "missing", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
|
||||
t.Fatalf("err=%v, want no result validation", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query too long", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
|
||||
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
|
||||
t.Fatalf("err=%v, want query length validation", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
|
||||
if items == nil {
|
||||
items = []interface{}{}
|
||||
}
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"res_units": items,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"total": 2,
|
||||
"fields": []interface{}{
|
||||
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
|
||||
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{recordID},
|
||||
"field_id_list": []interface{}{"fld_name", "fld_status"},
|
||||
"fields": []interface{}{"Name", "Status"},
|
||||
"data": []interface{}{[]interface{}{"Alice", "Done"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
|
||||
func TestShortcutsCatalog(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
want := []string{
|
||||
"+url-resolve", "+title-resolve",
|
||||
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
|
||||
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
|
||||
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
|
||||
|
||||
@@ -8,6 +8,8 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
// Shortcuts returns all base shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
BaseURLResolve,
|
||||
BaseTitleResolve,
|
||||
BaseBaseBlockList,
|
||||
BaseBaseBlockCreate,
|
||||
BaseBaseBlockMove,
|
||||
|
||||
@@ -199,16 +199,7 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T (%v)", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,9 +142,7 @@ func TestNormalizeMCPToolResult(t *testing.T) {
|
||||
|
||||
got, err := normalizeMCPToolResult(tt.raw)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,7 @@ type RuntimeContext struct {
|
||||
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
|
||||
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
|
||||
larkSDK *lark.Client // eagerly initialized in mountDeclarative
|
||||
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
|
||||
}
|
||||
|
||||
// ── Identity ──
|
||||
@@ -223,6 +224,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
|
||||
func (ctx *RuntimeContext) IntArray(name string) []int {
|
||||
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -1023,7 +1030,6 @@ func stripUTF8BOM(s string) string {
|
||||
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
|
||||
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
|
||||
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
stdinUsed := false
|
||||
for _, fl := range flags {
|
||||
if len(fl.Input) == 0 {
|
||||
continue
|
||||
@@ -1043,11 +1049,14 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
|
||||
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
}
|
||||
if stdinUsed {
|
||||
// A process has a single stdin, so we reject a second Input flag
|
||||
// trying to use `-` after the first one has already consumed it.
|
||||
if rctx.stdinConsumed {
|
||||
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
|
||||
WithParam("--" + fl.Name)
|
||||
WithParam("--"+fl.Name).
|
||||
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
|
||||
}
|
||||
stdinUsed = true
|
||||
rctx.stdinConsumed = true
|
||||
data, err := io.ReadAll(rctx.IO().In)
|
||||
if err != nil {
|
||||
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
|
||||
@@ -1160,7 +1169,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
hints = append(hints, "@file")
|
||||
}
|
||||
if slices.Contains(fl.Input, Stdin) {
|
||||
hints = append(hints, "- for stdin")
|
||||
// "- reads stdin" intentionally avoids implying each flag has
|
||||
// its own stdin: a process has a single stdin, so at most one
|
||||
// flag per call may use "-" (the rest must use @file). The old
|
||||
// per-flag "- for stdin" wording led AI agents to write
|
||||
// `--a - <x --b - <y`, where the second `<` silently clobbers
|
||||
// the first and `--a` reads the wrong payload.
|
||||
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
|
||||
}
|
||||
desc += " (supports " + strings.Join(hints, ", ") + ")"
|
||||
}
|
||||
@@ -1176,6 +1191,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "int_array":
|
||||
cmd.Flags().IntSlice(fl.Name, nil, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -19,6 +22,7 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for positional arg, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("expected positional args rejection message, got: %v", err)
|
||||
}
|
||||
@@ -36,6 +40,7 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multiple positional args, got nil")
|
||||
}
|
||||
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "positional arguments are not supported") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
@@ -56,3 +61,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagIntArray(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
var got []int
|
||||
shortcut := Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "capture screenshots",
|
||||
Flags: []Flag{
|
||||
{Name: "slide-number", Type: "int_array"},
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
|
||||
got = runtime.IntArray("slide-number")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("slide-number = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "[99991]") {
|
||||
t.Errorf("error = %q, want substring [99991]", err.Error())
|
||||
}
|
||||
@@ -197,6 +198,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty open_id")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "open_id is empty") {
|
||||
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
|
||||
}
|
||||
@@ -218,6 +220,7 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error = %q, want substring '403'", err.Error())
|
||||
}
|
||||
@@ -238,7 +241,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper
|
||||
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
|
||||
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
|
||||
t.Errorf("error = %q, want JSON parse failure", err.Error())
|
||||
}
|
||||
@@ -279,6 +282,7 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
|
||||
if info != nil {
|
||||
t.Errorf("expected nil info, got %+v", info)
|
||||
}
|
||||
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not available") {
|
||||
t.Errorf("error = %q, want substring 'not available'", err.Error())
|
||||
}
|
||||
@@ -291,6 +295,7 @@ func TestBotInfo_NilFunc(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil botInfoFunc")
|
||||
}
|
||||
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
|
||||
if !strings.Contains(err.Error(), "not fully initialized") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for stdin not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support stdin") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support stdin") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for file not supported")
|
||||
}
|
||||
assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(err.Error(), "does not support file input") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--data")
|
||||
if !strings.Contains(vErr.Message, "does not support file input") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "cannot read file") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty file path")
|
||||
}
|
||||
assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--markdown")
|
||||
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,9 +216,14 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate stdin usage")
|
||||
}
|
||||
assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
vErr := assertValidationParam(t, err, "--b")
|
||||
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
|
||||
t.Errorf("unexpected error message: %q", vErr.Message)
|
||||
}
|
||||
// The hint must steer an AI agent to the fix (@file for the extra flags),
|
||||
// since `--a - <x --b - <y` is the exact misuse this guards against.
|
||||
if !strings.Contains(vErr.Hint, "@file") {
|
||||
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,9 +186,7 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
||||
}
|
||||
requireValidation(t, err, "mutually exclusive")
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
@@ -208,9 +206,7 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid jq expression")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid jq expression") {
|
||||
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
||||
}
|
||||
requireValidation(t, err, "invalid jq expression")
|
||||
}
|
||||
|
||||
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {
|
||||
|
||||
50
shortcuts/common/typed_error_assertions_test.go
Normal file
50
shortcuts/common/typed_error_assertions_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// requireProblem asserts err carries a typed errs.Problem with the given
|
||||
// category and (optional) subtype, and that its message contains msgContains
|
||||
// (skip the message check by passing ""). Returns the Problem so callers can
|
||||
// drill into the typed envelope's category-specific fields (e.g. cast to
|
||||
// *errs.ValidationError to read .Param / .Params / .Cause).
|
||||
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != wantCategory {
|
||||
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
|
||||
}
|
||||
if wantSubtype != "" && p.Subtype != wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
|
||||
}
|
||||
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
|
||||
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
|
||||
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
|
||||
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return ve
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -85,6 +85,7 @@ type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type searchUserAPIItem struct {
|
||||
@@ -126,6 +127,7 @@ type searchUser struct {
|
||||
type searchUserResponse struct {
|
||||
Users []searchUser `json:"users"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchUser = common.Shortcut{
|
||||
@@ -189,6 +191,7 @@ var ContactSearchUser = common.Shortcut{
|
||||
Execute: executeSearchUser,
|
||||
}
|
||||
|
||||
// executeSearchUser dispatches contact search to single-query or fanout mode.
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
@@ -196,6 +199,7 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// executeSearchUserSingle performs one contact search and preserves server notices.
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
@@ -222,7 +226,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
if len(users) == 0 {
|
||||
|
||||
@@ -45,22 +45,17 @@ type fanoutResult struct {
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
// runOneQuery converts one fanout request into either users or an error summary.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
@@ -94,9 +89,10 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
}
|
||||
|
||||
// fanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
@@ -113,17 +109,16 @@ type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
@@ -142,6 +137,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
@@ -152,6 +148,9 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
|
||||
@@ -562,6 +562,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
func searchUserStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -569,6 +570,7 @@ func searchUserStub() *httpmock.Stub {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
@@ -590,6 +592,7 @@ func searchUserStub() *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
|
||||
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -614,6 +617,7 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
|
||||
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -631,6 +635,9 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
|
||||
@@ -1358,6 +1365,7 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
@@ -1399,6 +1407,7 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1406,6 +1415,7 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
@@ -1432,10 +1442,17 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q0 := queries[0].(map[string]interface{})
|
||||
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("queries[0].notice = %v", q0["notice"])
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
|
||||
@@ -203,6 +203,13 @@ func TestValidateCreateV2Contract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCreateV2AllowsTitleWithoutContent(t *testing.T) {
|
||||
rt := docValidateRuntime(t, map[string]string{"title": "Only Title"}, nil, nil)
|
||||
if err := validateCreateV2(context.Background(), rt); err != nil {
|
||||
t.Fatalf("validateCreateV2() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFetchV2Contract(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -55,7 +55,7 @@ var docCoverAllowedContentTypes = map[string]string{
|
||||
|
||||
var DocResourceDownload = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-download",
|
||||
Command: "+resource-download",
|
||||
Description: "Download a document resource (type=cover downloads the cover image content)",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
||||
@@ -154,7 +154,7 @@ var DocResourceDownload = common.Shortcut{
|
||||
|
||||
var DocResourceUpdate = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-update",
|
||||
Command: "+resource-update",
|
||||
Description: "Upload and update a document resource (type=cover)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
||||
@@ -256,7 +256,7 @@ var DocResourceUpdate = common.Shortcut{
|
||||
|
||||
var DocResourceDelete = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "resource-delete",
|
||||
Command: "+resource-delete",
|
||||
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"+resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover",
|
||||
@@ -95,7 +95,7 @@ func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T)
|
||||
withDocsWorkingDir(t, tmpDir)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDownload, []string{
|
||||
"resource-download",
|
||||
"+resource-download",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--output", "cover.png",
|
||||
@@ -116,7 +116,7 @@ func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
|
||||
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"+resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -146,7 +146,7 @@ func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceDelete, []string{
|
||||
"resource-delete",
|
||||
"+resource-delete",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -195,7 +195,7 @@ func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *tes
|
||||
reg.Register(patchStub)
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", documentID,
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -241,7 +241,7 @@ func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", "doxcnCoverValidate1",
|
||||
"--type", "cover",
|
||||
"--file", "cover.png",
|
||||
@@ -258,7 +258,7 @@ func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", "doxcnCoverValidateRequired1",
|
||||
"--type", "cover",
|
||||
"--as", "bot",
|
||||
@@ -273,7 +273,7 @@ func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
|
||||
|
||||
err := mountAndRunDocs(t, DocResourceUpdate, []string{
|
||||
"resource-update",
|
||||
"+resource-update",
|
||||
"--doc", "doxcnCoverURLValidate1",
|
||||
"--type", "cover",
|
||||
"--url", "https://127.0.0.1/cover.png",
|
||||
@@ -617,7 +617,7 @@ func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
|
||||
for _, shortcut := range Shortcuts() {
|
||||
got[shortcut.Command] = true
|
||||
}
|
||||
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
|
||||
for _, want := range []string{"+resource-download", "+resource-update", "+resource-delete"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("Shortcuts() missing %s", want)
|
||||
}
|
||||
|
||||
@@ -282,7 +282,6 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "user",
|
||||
})
|
||||
@@ -292,8 +291,7 @@ func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"docs +create is v2-only",
|
||||
"the old v1 interface has been shut down",
|
||||
"legacy v1 flag(s) --title, --markdown are no longer supported",
|
||||
"--title -> put the title in --content",
|
||||
"legacy v1 flag(s) --markdown are no longer supported",
|
||||
"--markdown -> use --content with --doc-format markdown",
|
||||
"lark-cli skills read lark-doc references/lark-doc-create.md",
|
||||
"lark-cli skills read lark-doc references/lark-doc-xml.md",
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
|
||||
@@ -25,8 +28,12 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Str("content") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if runtime.Changed("title") && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title")
|
||||
}
|
||||
if runtime.Str("content") == "" && title == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content")
|
||||
}
|
||||
if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams(
|
||||
@@ -66,7 +73,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"format": runtime.Str("doc-format"),
|
||||
"content": runtime.Str("content"),
|
||||
"content": buildCreateContent(runtime),
|
||||
}
|
||||
if v := runtime.Str("parent-token"); v != "" {
|
||||
body["parent_token"] = v
|
||||
@@ -78,6 +85,26 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
return body
|
||||
}
|
||||
|
||||
func buildCreateContent(runtime *common.RuntimeContext) string {
|
||||
content := runtime.Str("content")
|
||||
title := strings.TrimSpace(runtime.Str("title"))
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
return titleTag
|
||||
}
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// augmentDocsCreatePermission grants full_access to the current CLI user when
|
||||
// the document was created with bot identity.
|
||||
func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string]interface{}) {
|
||||
|
||||
861
shortcuts/doc/docs_fetch_im_markdown.go
Normal file
861
shortcuts/doc/docs_fetch_im_markdown.go
Normal file
@@ -0,0 +1,861 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type imMarkdownContext struct {
|
||||
baseURL string
|
||||
blockquoteDepth int
|
||||
}
|
||||
|
||||
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
|
||||
|
||||
type imMarkdownTagHandler struct {
|
||||
closeRE *regexp.Regexp
|
||||
handle imMarkdownHandleFunc
|
||||
}
|
||||
|
||||
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
|
||||
imMarkdownHandlers[tag] = imMarkdownTagHandler{
|
||||
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
|
||||
handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
|
||||
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
|
||||
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
|
||||
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
|
||||
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
|
||||
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
|
||||
)
|
||||
|
||||
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
|
||||
|
||||
func init() {
|
||||
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
|
||||
for level := 1; level <= 9; level++ {
|
||||
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
|
||||
}
|
||||
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
|
||||
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
|
||||
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
|
||||
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
|
||||
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
|
||||
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
|
||||
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
|
||||
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
|
||||
registerIMMarkdownHandler("table", handleIMMarkdownTable)
|
||||
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
|
||||
registerIMMarkdownHandler("code", handleIMMarkdownCode)
|
||||
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
|
||||
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
|
||||
registerIMMarkdownHandler("img", handleIMMarkdownImage)
|
||||
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("source", handleIMMarkdownSource)
|
||||
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
|
||||
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
|
||||
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
|
||||
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
|
||||
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
|
||||
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
|
||||
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
|
||||
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
|
||||
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
|
||||
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
|
||||
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
|
||||
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
|
||||
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
|
||||
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
|
||||
}
|
||||
|
||||
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
|
||||
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
|
||||
}
|
||||
|
||||
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
|
||||
doc, ok := data["document"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
content, ok := doc["content"].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
|
||||
}
|
||||
|
||||
func newIMMarkdownContext(docInput string) imMarkdownContext {
|
||||
base := "https://larkoffice.com"
|
||||
raw := strings.TrimSpace(docInput)
|
||||
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
|
||||
base = extracted
|
||||
}
|
||||
return imMarkdownContext{baseURL: base}
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
|
||||
c.blockquoteDepth++
|
||||
return c
|
||||
}
|
||||
|
||||
func (c imMarkdownContext) inBlockquote() bool {
|
||||
return c.blockquoteDepth > 0
|
||||
}
|
||||
|
||||
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
|
||||
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
|
||||
// intentionally strips host information, so it cannot serve this formatting path.
|
||||
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
|
||||
if raw == "" {
|
||||
return "", false
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
|
||||
idx := strings.Index(raw, marker)
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
candidate := strings.Trim(raw[:idx], "/")
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Scheme + "://" + u.Host, true
|
||||
}
|
||||
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
|
||||
return "https://" + u.Host, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset := 0; offset < len(content); {
|
||||
// Scan only to the next XML-like opening tag. Plain Markdown text between
|
||||
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
|
||||
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
out.WriteString(content[offset:])
|
||||
break
|
||||
}
|
||||
start := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
|
||||
handler, ok := imMarkdownHandlers[tag]
|
||||
if !ok {
|
||||
// Unknown tags are left intact. im-markdown only downgrades tags with
|
||||
// explicit handlers so future server output does not get guessed at.
|
||||
out.WriteString(content[offset:openEnd])
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteString(content[offset:start])
|
||||
opening := content[start:openEnd]
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
out.WriteString(handler.handle(opening, "", attrs, imCtx))
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
|
||||
// Use the handler's precompiled close regexp to find the matching end tag.
|
||||
// Depth tracking keeps nested same-name containers paired correctly.
|
||||
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
|
||||
if !found {
|
||||
// Malformed or truncated fragments are preserved as-is from the opening
|
||||
// tag onward; do not drop content when the XML-ish structure is incomplete.
|
||||
out.WriteString(content[start:])
|
||||
break
|
||||
}
|
||||
segment := content[start:closeEnd]
|
||||
inner := content[openEnd:closeStart]
|
||||
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
|
||||
offset = closeEnd
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func parseIMMarkdownAttrs(opening string) map[string]string {
|
||||
attrs := map[string]string{}
|
||||
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
|
||||
value := match[2]
|
||||
if value == "" {
|
||||
value = match[3]
|
||||
}
|
||||
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
func isSelfClosingIMMarkdownTag(tag string) bool {
|
||||
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
|
||||
}
|
||||
|
||||
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "# " + text
|
||||
}
|
||||
|
||||
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
|
||||
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
markdownLevel := level
|
||||
if markdownLevel > 6 {
|
||||
markdownLevel = 6
|
||||
}
|
||||
return strings.Repeat("#", markdownLevel) + " " + text
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
if imCtx.inBlockquote() {
|
||||
return body + "\n"
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, false, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return convertIMMarkdownListItems(inner, true, imCtx)
|
||||
}
|
||||
|
||||
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
prefix := "-"
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
emoji := strings.TrimSpace(attrs["emoji"])
|
||||
if emoji != "" {
|
||||
if body == "" {
|
||||
body = emoji
|
||||
} else {
|
||||
body = emoji + " " + body
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
return "---\n---"
|
||||
}
|
||||
return fmt.Sprintf("---\n%s\n---", body)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
lines[i] = ">"
|
||||
continue
|
||||
}
|
||||
lines[i] = "> " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return body + "\n"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
lang := strings.TrimSpace(attrs["lang"])
|
||||
code := strings.TrimSpace(inner)
|
||||
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
|
||||
code = match[1]
|
||||
}
|
||||
return imMarkdownFencedCode(html.UnescapeString(code), lang)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(markdownPlainText(inner))
|
||||
}
|
||||
|
||||
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
|
||||
expr := strings.TrimSpace(markdownPlainText(inner))
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
|
||||
}
|
||||
|
||||
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return "---"
|
||||
}
|
||||
|
||||
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
|
||||
if href == "" {
|
||||
return ""
|
||||
}
|
||||
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
|
||||
return fmt.Sprintf("", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
|
||||
}
|
||||
|
||||
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
name := strings.TrimSpace(attrs["name"])
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return imMarkdownInlineCode(name)
|
||||
}
|
||||
|
||||
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
|
||||
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
|
||||
for _, attrName := range attrNames {
|
||||
if strings.TrimSpace(attrs[attrName]) != "" {
|
||||
return imMarkdownInlineCode(label)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
token := strings.TrimSpace(attrs["token"])
|
||||
if token == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
label := "sheet"
|
||||
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
|
||||
label = "sheet " + sheetID
|
||||
}
|
||||
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
|
||||
}
|
||||
|
||||
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
|
||||
if href == "" {
|
||||
return name
|
||||
}
|
||||
return markdownLink(name, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "**" + body + "**"
|
||||
}
|
||||
|
||||
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "*" + body + "*"
|
||||
}
|
||||
|
||||
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
if body == "" {
|
||||
return ""
|
||||
}
|
||||
return "~~" + body + "~~"
|
||||
}
|
||||
|
||||
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
|
||||
}
|
||||
|
||||
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
href := strings.TrimSpace(attrs["href"])
|
||||
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
|
||||
if href == "" {
|
||||
return text
|
||||
}
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
|
||||
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
|
||||
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
|
||||
case "user":
|
||||
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
|
||||
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
|
||||
if userID == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
|
||||
case "doc":
|
||||
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(title, href)
|
||||
}
|
||||
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
|
||||
if docID == "" {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
|
||||
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
|
||||
case "citation":
|
||||
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
|
||||
return markdownLink(text, href)
|
||||
}
|
||||
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
|
||||
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
|
||||
}
|
||||
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
|
||||
default:
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
|
||||
// Rows and cells are matched with tag-depth tracking instead of non-greedy
|
||||
// regex captures. A table nested inside a cell can contain its own </tr> and
|
||||
// </td>; treating those as the outer row/cell boundary corrupts the table.
|
||||
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
|
||||
if len(rowBodies) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, len(rowBodies))
|
||||
for _, rowBody := range rowBodies {
|
||||
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
|
||||
if len(cellBodies) == 0 {
|
||||
continue
|
||||
}
|
||||
row := make([]string, 0, len(cellBodies))
|
||||
for _, cellBody := range cellBodies {
|
||||
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return imMarkdownInlineCode(segment)
|
||||
}
|
||||
|
||||
cols := 0
|
||||
for _, row := range rows {
|
||||
if len(row) > cols {
|
||||
cols = len(row)
|
||||
}
|
||||
}
|
||||
var out strings.Builder
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
|
||||
separator := make([]string, cols)
|
||||
for i := range separator {
|
||||
separator[i] = "-"
|
||||
}
|
||||
writeIMMarkdownTableRow(&out, separator)
|
||||
for _, row := range rows[1:] {
|
||||
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// extractIMMarkdownElementBodies returns the inner content of each top-level
|
||||
// element matched by tagRE. tagRE must expose the optional closing slash as its
|
||||
// first capture group, matching the row/cell regexes above.
|
||||
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
|
||||
var bodies []string
|
||||
for offset := 0; offset < len(content); {
|
||||
loc := tagRE.FindStringSubmatchIndex(content[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := content[openStart:openEnd]
|
||||
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
if isSelfClosingIMMarkdownTag(opening) {
|
||||
bodies = append(bodies, "")
|
||||
offset = openEnd
|
||||
continue
|
||||
}
|
||||
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
bodies = append(bodies, content[openEnd:closeStart])
|
||||
offset = closeEnd
|
||||
}
|
||||
return bodies
|
||||
}
|
||||
|
||||
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func normalizeIMMarkdownTableCell(cell string) string {
|
||||
const brPlaceholder = "\x00BR\x00"
|
||||
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
|
||||
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
|
||||
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
|
||||
if name == "at" {
|
||||
return tag
|
||||
}
|
||||
return ""
|
||||
})
|
||||
cell = html.UnescapeString(cell)
|
||||
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
|
||||
cell = strings.ReplaceAll(cell, " \n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "\n", "<br>")
|
||||
cell = strings.ReplaceAll(cell, "|", `\|`)
|
||||
lines := strings.Fields(cell)
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
|
||||
out.WriteString("| ")
|
||||
out.WriteString(strings.Join(row, " | "))
|
||||
out.WriteString(" |\n")
|
||||
}
|
||||
|
||||
func padIMMarkdownTableRow(row []string, cols int) []string {
|
||||
if len(row) >= cols {
|
||||
return row
|
||||
}
|
||||
padded := make([]string, cols)
|
||||
copy(padded, row)
|
||||
return padded
|
||||
}
|
||||
|
||||
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
|
||||
var out strings.Builder
|
||||
for offset, index := 0, 1; offset < len(inner); {
|
||||
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
openStart := offset + loc[0]
|
||||
openEnd := offset + loc[1]
|
||||
opening := inner[openStart:openEnd]
|
||||
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
|
||||
if body != "" {
|
||||
prefix := "-"
|
||||
if ordered {
|
||||
attrs := parseIMMarkdownAttrs(opening)
|
||||
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
|
||||
prefix = strings.TrimSuffix(seq, ".") + "."
|
||||
} else {
|
||||
prefix = fmt.Sprintf("%d.", index)
|
||||
}
|
||||
index++
|
||||
}
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(" ")
|
||||
out.WriteString(indentIMMarkdownListContinuation(body))
|
||||
out.WriteString("\n")
|
||||
}
|
||||
offset = closeEnd
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
|
||||
depth := 1
|
||||
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
|
||||
start := from + loc[0]
|
||||
end := from + loc[1]
|
||||
token := content[start:end]
|
||||
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return start, end, true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !isSelfClosingIMMarkdownTag(token) {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func indentIMMarkdownListContinuation(body string) string {
|
||||
return strings.ReplaceAll(body, "\n", "\n ")
|
||||
}
|
||||
|
||||
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
|
||||
match := imMarkdownLinkRE.FindStringSubmatch(inner)
|
||||
if match == nil {
|
||||
return "", "", false
|
||||
}
|
||||
href := match[1]
|
||||
if href == "" {
|
||||
href = match[2]
|
||||
}
|
||||
text := strings.TrimSpace(markdownPlainText(match[3]))
|
||||
if text == "" {
|
||||
text = href
|
||||
}
|
||||
return text, html.UnescapeString(href), true
|
||||
}
|
||||
|
||||
func markdownPlainText(s string) string {
|
||||
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
|
||||
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func markdownLinkLabelText(s string) string {
|
||||
text := markdownPlainText(s)
|
||||
if !strings.Contains(text, "---") {
|
||||
return text
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
kept := lines[:0]
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n"))
|
||||
}
|
||||
|
||||
func markdownLink(text, href string) string {
|
||||
cleanHref := strings.TrimSpace(href)
|
||||
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkText(text string) string {
|
||||
text = strings.ReplaceAll(text, `\`, `\\`)
|
||||
text = strings.ReplaceAll(text, `[`, `\[`)
|
||||
text = strings.ReplaceAll(text, `]`, `\]`)
|
||||
return text
|
||||
}
|
||||
|
||||
func escapeMarkdownLinkDestination(href string) string {
|
||||
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
|
||||
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
|
||||
// characters that can terminate or split the Markdown link destination.
|
||||
var out strings.Builder
|
||||
out.Grow(len(href))
|
||||
for i := 0; i < len(href); {
|
||||
if href[i] == '%' {
|
||||
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
|
||||
out.WriteString(href[i : i+3])
|
||||
i += 3
|
||||
} else {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if href[i] < utf8.RuneSelf {
|
||||
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
} else {
|
||||
out.WriteByte(href[i])
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRuneInString(href[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
writePercentEncodedByte(&out, href[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
for _, b := range []byte(href[i : i+size]) {
|
||||
writePercentEncodedByte(&out, b)
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
|
||||
if b <= ' ' || b >= 0x7f {
|
||||
return true
|
||||
}
|
||||
switch b {
|
||||
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func writePercentEncodedByte(out *strings.Builder, b byte) {
|
||||
const hex = "0123456789ABCDEF"
|
||||
out.WriteByte('%')
|
||||
out.WriteByte(hex[b>>4])
|
||||
out.WriteByte(hex[b&0x0f])
|
||||
}
|
||||
|
||||
func isHexDigit(b byte) bool {
|
||||
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
|
||||
}
|
||||
|
||||
func imMarkdownInlineCode(s string) string {
|
||||
maxRun := 0
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r == '`' {
|
||||
run++
|
||||
if run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
fence := strings.Repeat("`", maxRun+1)
|
||||
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
|
||||
return fence + " " + s + " " + fence
|
||||
}
|
||||
return fence + s + fence
|
||||
}
|
||||
|
||||
func imMarkdownFencedCode(code, lang string) string {
|
||||
maxRun := 0
|
||||
for _, line := range strings.Split(code, "\n") {
|
||||
if run := leadingBacktickRun(line); run > maxRun {
|
||||
maxRun = run
|
||||
}
|
||||
}
|
||||
fenceLen := maxRun + 1
|
||||
if fenceLen < 3 {
|
||||
fenceLen = 3
|
||||
}
|
||||
fence := strings.Repeat("`", fenceLen)
|
||||
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
|
||||
}
|
||||
|
||||
func leadingBacktickRun(s string) int {
|
||||
run := 0
|
||||
for _, r := range s {
|
||||
if r != '`' {
|
||||
break
|
||||
}
|
||||
run++
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
1305
shortcuts/doc/docs_fetch_im_markdown_test.go
Normal file
1305
shortcuts/doc/docs_fetch_im_markdown_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user