mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
Compare commits
30 Commits
fix/wiki-n
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f495cbb166 | ||
|
|
6f95c5eb22 | ||
|
|
4e2cbea94e | ||
|
|
f98dbfe247 | ||
|
|
40ea4d60ef | ||
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 | ||
|
|
7db899db01 | ||
|
|
c2d6038aae | ||
|
|
efa3439e01 | ||
|
|
9f150670f3 | ||
|
|
578e2db4e0 | ||
|
|
94139751d3 | ||
|
|
8c3ed5d224 | ||
|
|
c982df4cf0 | ||
|
|
fb5ae41bca | ||
|
|
87e872a4c1 | ||
|
|
ddc0f2a521 | ||
|
|
440867f1b4 |
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
@@ -263,13 +263,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Run dry-run E2E tests
|
- name: Run dry-run E2E tests
|
||||||
env:
|
env:
|
||||||
@@ -277,7 +283,28 @@ jobs:
|
|||||||
LARKSUITE_CLI_APP_ID: dry-run
|
LARKSUITE_CLI_APP_ID: dry-run
|
||||||
LARKSUITE_CLI_APP_SECRET: dry-run
|
LARKSUITE_CLI_APP_SECRET: dry-run
|
||||||
LARKSUITE_CLI_BRAND: feishu
|
LARKSUITE_CLI_BRAND: feishu
|
||||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
|
||||||
|
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
|
||||||
|
run: |
|
||||||
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No dry-run CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
|
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
|
||||||
|
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
|
||||||
|
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
|
||||||
|
fi
|
||||||
|
if [ -n "$E2E_DRY_PACKAGES" ]; then
|
||||||
|
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
|
||||||
|
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
|
||||||
|
fi
|
||||||
|
|
||||||
e2e-live:
|
e2e-live:
|
||||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||||
@@ -292,15 +319,22 @@ jobs:
|
|||||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.x'
|
python-version: '3.x'
|
||||||
|
- name: Resolve CLI E2E domains
|
||||||
|
id: e2e_domains
|
||||||
|
run: node scripts/e2e_domains.js
|
||||||
- name: Build lark-cli
|
- name: Build lark-cli
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: make build
|
run: make build
|
||||||
- name: Configure bot credentials
|
- name: Configure bot credentials
|
||||||
|
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||||
@@ -310,16 +344,24 @@ jobs:
|
|||||||
- name: Run CLI E2E tests
|
- name: Run CLI E2E tests
|
||||||
env:
|
env:
|
||||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||||
|
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||||
|
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||||
|
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||||
run: |
|
run: |
|
||||||
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
|
if [ "$E2E_MODE" = "skip" ]; then
|
||||||
|
echo "No live CLI E2E needed: $E2E_REASON"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
packages="$E2E_LIVE_PACKAGES"
|
||||||
if [ -z "$packages" ]; then
|
if [ -z "$packages" ]; then
|
||||||
echo "No CLI E2E packages to test after exclusions."
|
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
|
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
|
||||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
echo "Live CLI E2E packages: $packages"
|
||||||
|
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||||
- name: Publish CLI E2E test report
|
- name: Publish CLI E2E test report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||||
with:
|
with:
|
||||||
name: CLI E2E Tests
|
name: CLI E2E Tests
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
docs/ref
|
docs/ref
|
||||||
docs/
|
docs/
|
||||||
|
!tests/cli_e2e/docs/
|
||||||
|
!tests/cli_e2e/docs/*.go
|
||||||
|
!tests/cli_e2e/docs/*.md
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -2,6 +2,71 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.66] - 2026-07-07
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- support semantic recurring calendar operations (#1723)
|
||||||
|
- minute wait (#1768)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- guide drive import concurrency conflicts (#1751)
|
||||||
|
- **calendar**: guide approval room booking fallback (#1637)
|
||||||
|
- support pnpm global installs in self-update (#1705)
|
||||||
|
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- tighten doc creation validation workflow (#1759)
|
||||||
|
- clarify success envelope contract — judge success by ok, not code (#1730)
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- **envvars**: consolidate agent env value access (#1757)
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
|
||||||
|
|
||||||
|
## [v1.0.65] - 2026-07-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||||
|
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||||
|
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||||
|
|
||||||
|
## [v1.0.64] - 2026-07-02
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
|
||||||
|
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
|
||||||
|
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
|
||||||
|
- **cli**: Improve secure label error handling (#1707)
|
||||||
|
- **cli**: Reduce public content token false positives
|
||||||
|
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
|
||||||
|
- **doc**: Align word statistics compound tokens (#1706)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
|
||||||
|
- **doc**: Support `reference_map` in docs (#1690)
|
||||||
|
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
|
||||||
|
|
||||||
## [v1.0.62] - 2026-07-01
|
## [v1.0.62] - 2026-07-01
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1333,6 +1398,9 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||||
|
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||||
|
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -51,7 +51,7 @@ script-test:
|
|||||||
bash scripts/resolve-changed-from.test.sh
|
bash scripts/resolve-changed-from.test.sh
|
||||||
bash scripts/ci-workflow.test.sh
|
bash scripts/ci-workflow.test.sh
|
||||||
bash scripts/semantic-review-workflow.test.sh
|
bash scripts/semantic-review-workflow.test.sh
|
||||||
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||||
|
|
||||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||||
unit-test: fetch_meta
|
unit-test: fetch_meta
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # Comma-separated values
|
--format csv # Comma-separated values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON Output Contract
|
||||||
|
|
||||||
|
With `--format json` (the default), success and error envelopes are distinct.
|
||||||
|
|
||||||
|
Success goes to **stdout**, exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors go to **stderr**, non-zero exit code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--format csv # 逗号分隔值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON 输出契约
|
||||||
|
|
||||||
|
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||||
|
|
||||||
|
成功信封写入 **stdout**,退出码 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
错误信封写入 **stderr**,退出码非 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||||
|
|
||||||
### 分页
|
### 分页
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,13 +20,28 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||||
|
cmd := NewCmdApi(f, runF)
|
||||||
|
cmd.SilenceErrors = true
|
||||||
|
cmd.SilenceUsage = true
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestRootCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "lark-cli",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||||
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
|||||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET"}) // missing path
|
cmd.SetArgs([]string{"GET"}) // missing path
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
fn := cmd.ValidArgsFunction
|
fn := cmd.ValidArgsFunction
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
flag := cmd.Flags().Lookup("as")
|
flag := cmd.Flags().Lookup("as")
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
t.Fatal("expected --as flag to be registered")
|
t.Fatal("expected --as flag to be registered")
|
||||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|||||||
ContentType: "application/octet-stream",
|
ContentType: "application/octet-stream",
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
// Should return an error
|
// Should return an error
|
||||||
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := root.Execute()
|
err := root.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||||
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||||
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||||
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||||
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||||
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||||
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|||||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -82,40 +80,6 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFlagDidYouMean_WikiNodeGetSuggestsNodeToken(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
|
|
||||||
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
|
|
||||||
root.SetArgs([]string{
|
|
||||||
"wiki", "+node-get",
|
|
||||||
"--node", "https://feishu.cn/wiki/wikcnABC",
|
|
||||||
"--as", "user",
|
|
||||||
})
|
|
||||||
|
|
||||||
err := root.Execute()
|
|
||||||
var verr *errs.ValidationError
|
|
||||||
if !errors.As(err, &verr) {
|
|
||||||
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
|
|
||||||
}
|
|
||||||
if len(verr.Params) != 1 || verr.Params[0].Name != "--node" {
|
|
||||||
t.Fatalf("Params = %v, want one entry named --node", verr.Params)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, s := range verr.Params[0].Suggestions {
|
|
||||||
if s == "--node-token" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("Params[0].Suggestions = %v, want --node-token", verr.Params[0].Suggestions)
|
|
||||||
}
|
|
||||||
if !strings.Contains(verr.Hint, "--node-token") {
|
|
||||||
t.Fatalf("hint = %q, want --node-token", verr.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
|
||||||
c := &cobra.Command{Use: "demo"}
|
c := &cobra.Command{Use: "demo"}
|
||||||
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import (
|
|||||||
"github.com/larksuite/cli/cmd/api"
|
"github.com/larksuite/cli/cmd/api"
|
||||||
"github.com/larksuite/cli/cmd/auth"
|
"github.com/larksuite/cli/cmd/auth"
|
||||||
"github.com/larksuite/cli/cmd/service"
|
"github.com/larksuite/cli/cmd/service"
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/envvars"
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/skillscheck"
|
"github.com/larksuite/cli/internal/skillscheck"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"github.com/larksuite/cli/internal/update"
|
||||||
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||||
|
t.Helper()
|
||||||
|
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||||
rootCmd.SilenceErrors = true
|
rootCmd.SilenceErrors = true
|
||||||
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
}
|
}
|
||||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||||
service.RegisterServiceCommands(rootCmd, f)
|
if catalog != nil {
|
||||||
|
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
|
||||||
|
} else {
|
||||||
|
service.RegisterServiceCommands(rootCmd, f)
|
||||||
|
}
|
||||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||||
pruneForStrictMode(rootCmd, mode)
|
pruneForStrictMode(rootCmd, mode)
|
||||||
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
|
|||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func strictModeFixtureCatalog() apicatalog.Catalog {
|
||||||
|
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
||||||
|
{
|
||||||
|
Name: "fixture",
|
||||||
|
ServicePath: "/open-apis/fixture/v1",
|
||||||
|
Resources: map[string]meta.Resource{
|
||||||
|
"things": {
|
||||||
|
Methods: map[string]meta.Method{
|
||||||
|
"create": {
|
||||||
|
Path: "things",
|
||||||
|
HTTPMethod: "POST",
|
||||||
|
AccessTokens: []meta.Token{meta.TokenTenant},
|
||||||
|
RequestBody: map[string]meta.Field{
|
||||||
|
"name": {Type: "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv(envvars.CliAppID, "")
|
t.Setenv(envvars.CliAppID, "")
|
||||||
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
|||||||
|
|
||||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
catalog := strictModeFixtureCatalog()
|
||||||
|
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||||
|
|
||||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
|
||||||
})
|
})
|
||||||
|
|
||||||
if code != output.ExitValidation {
|
if code != output.ExitValidation {
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||||
// It uses the embedded source so completion candidates match what `schema`
|
// It uses the same source as schema execution so completion candidates match
|
||||||
// execution can resolve (both overlay-free).
|
// what `schema` can resolve.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the embedded catalog and renders the
|
// runSchema resolves the path through the schema catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.EmbeddedCatalog()
|
catalog := registry.SchemaCatalog()
|
||||||
|
if len(catalog.Services()) == 0 {
|
||||||
|
// No embedded metadata and the runtime fallback is empty too: offline
|
||||||
|
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||||
|
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||||
|
}
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
Long: `Update lark-cli to the latest version.
|
Long: `Update lark-cli to the latest version.
|
||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
|
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
|
pm := "npm"
|
||||||
|
install := updater.RunNpmInstall
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
pm = "pnpm"
|
||||||
|
install = updater.RunPnpmInstall
|
||||||
|
}
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := updater.RunNpmInstall(latest)
|
npmResult := install(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined),
|
"hint": permissionHint(combined, pm),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined); hint != "" {
|
if hint := permissionHint(combined, pm); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest)
|
hint := verificationFailureHint(updater, latest, pm)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
skillsPM := "npx"
|
||||||
|
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||||
|
skillsPM = "pnpm dlx"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(npmOutput string) string {
|
func permissionHint(pmOutput, pm string) string {
|
||||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
if pm == "pnpm" {
|
||||||
|
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||||
|
}
|
||||||
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
}
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
return "the previous version has been restored"
|
||||||
}
|
}
|
||||||
|
if pm == "pnpm" {
|
||||||
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
|
}
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||||
|
// and fails the test if the npm install path is invoked.
|
||||||
|
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||||
|
t.Helper()
|
||||||
|
origNew := newUpdater
|
||||||
|
newUpdater = func() *selfupdate.Updater {
|
||||||
|
u := selfupdate.New()
|
||||||
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
|
u.PnpmInstallOverride = pnpmFn
|
||||||
|
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||||
|
t.Errorf("npm install must not be called for a pnpm install")
|
||||||
|
return &selfupdate.NpmResult{}
|
||||||
|
}
|
||||||
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||||
|
t.Errorf("expected updated in output, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Human(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "via pnpm") {
|
||||||
|
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||||
|
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
|
t.Errorf("expected success message, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||||
|
)
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error exit")
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||||
|
t.Errorf("expected failure envelope, got: %s", out)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||||
|
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||||
|
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pnpm add -g") {
|
||||||
|
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
t.Errorf("expected success message in stderr, got: %s", out)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||||
|
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||||
|
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||||
|
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||||
|
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||||
|
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
|
||||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied")
|
hint = permissionHint("EACCES: permission denied", "npm")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error"); got != "" {
|
if got := permissionHint("some other error", "npm"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`CategoryPolicy`.
|
||||||
|
|
||||||
|
### Success envelope (stdout)
|
||||||
|
|
||||||
|
For contrast: success responses render to **stdout** as an
|
||||||
|
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"identity": "user",
|
||||||
|
"data": { "guid": "e297d3d0-..." },
|
||||||
|
"meta": { "count": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers must branch on `ok` (or the process exit code). The success
|
||||||
|
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||||
|
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||||
|
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||||
|
will misclassify every successful call as a failure, which is
|
||||||
|
especially dangerous around write commands (e.g. retrying a create that
|
||||||
|
already succeeded).
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
|
|||||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||||
WithMissingScopes("mail:user_mailbox.message:send").
|
WithMissingScopes("mail:user_mailbox.message:send").
|
||||||
WithIdentity("user").
|
WithIdentity("user").
|
||||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
|
||||||
|
|
||||||
if got.Category != errs.CategoryAuthorization {
|
if got.Category != errs.CategoryAuthorization {
|
||||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||||
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||||
WithMissingScopes("calendar:event:create").
|
WithMissingScopes("calendar:event:create").
|
||||||
WithIdentity("user").
|
WithIdentity("user").
|
||||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
|
||||||
|
|
||||||
buf, err := json.Marshal(e)
|
buf, err := json.Marshal(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
|
|||||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||||
"log_id": "20260520-0a1b2c3d",
|
"log_id": "20260520-0a1b2c3d",
|
||||||
"identity": "user",
|
"identity": "user",
|
||||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
"console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
|
||||||
"missing_scopes": []any{"calendar:event:create"},
|
"missing_scopes": []any{"calendar:event:create"},
|
||||||
}
|
}
|
||||||
for k, want := range wantFields {
|
for k, want := range wantFields {
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
|||||||
// space→dot fallback covers domains where the two already coincide.
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
func commandFormResolver(service string) func(string) string {
|
func commandFormResolver(service string) func(string) string {
|
||||||
byForm := map[string]string{}
|
byForm := map[string]string{}
|
||||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||||
if svc.Name != service {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return func(h string) string {
|
return func(h string) string {
|
||||||
h = strings.TrimSpace(h)
|
h = strings.TrimSpace(h)
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -40,8 +38,6 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
return SourceValue + "/" + build.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentTraceValue returns a header-safe value from the
|
|
||||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
|
||||||
// surrounding whitespace, rejects values containing any Unicode
|
|
||||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
|
||||||
// for any invalid or empty value. Callers can use the result
|
|
||||||
// directly in HTTP headers without further sanitisation.
|
|
||||||
func AgentTraceValue() string {
|
|
||||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
|
||||||
if v == "" || len(v) > agentTraceMaxLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, r := range v {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseSecurityHeaders returns headers that every request must carry.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := AgentTraceValue(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AgentTraceValue / HeaderAgentTrace
|
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTraceValue(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " ")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, val)
|
|
||||||
if got := AgentTraceValue(); got != val {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
|
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentNameMaxLen = 128
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func AgentName() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentTrace() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" || len(v) > maxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "claude-code")
|
||||||
|
if got := AgentName(); got != "claude-code" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, " cursor ")
|
||||||
|
if got := AgentName(); got != "cursor" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\x01injected")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||||
|
t.Setenv(CliAgentName, longVal)
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTrace(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTrace(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " ")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(CliAgentTrace, longVal)
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(CliAgentTrace, val)
|
||||||
|
if got := AgentTrace(); got != val {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
// ClassifyContext is the contextual data BuildAPIError uses to populate
|
||||||
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
|
||||||
// Identity is a plain string ("user" / "bot" / "") so this package does not
|
// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
|
||||||
// depend on internal/core (which would create an import cycle).
|
// Brand through core.ParseBrand, so callers can pass a raw brand string without
|
||||||
|
// coupling this contract to core's brand enum.
|
||||||
type ClassifyContext struct {
|
type ClassifyContext struct {
|
||||||
Brand string // "feishu" | "lark" — drives console_url host
|
Brand string // "feishu" | "lark" — drives console_url host
|
||||||
AppID string // placed in console_url
|
AppID string // placed in console_url
|
||||||
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL,
|
// ConsoleURL composes the Feishu/Lark open-platform application-scope apply
|
||||||
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty
|
// page URL (the official open-pages `/page/scope-apply` entry), suitable for
|
||||||
// scopes list returns the bare /auth landing page; scopes are joined with
|
// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
|
||||||
// commas in the `q` query parameter so the console can pre-select them.
|
// returns the page carrying only clientID; otherwise scopes are joined with
|
||||||
|
// commas in the `scopes` query parameter so the console can pre-select them.
|
||||||
//
|
//
|
||||||
// brand is "feishu" or "lark"; unknown values default to feishu.
|
// brand is "feishu" or "lark"; unknown values default to feishu.
|
||||||
func ConsoleURL(brand, appID string, scopes []string) string {
|
func ConsoleURL(brand, appID string, scopes []string) string {
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
host := "open.feishu.cn"
|
// QueryEscape both values — clientID and scopes both sit in the query
|
||||||
if brand == "lark" {
|
// string, and untrusted content must not be able to inject extra query
|
||||||
host = "open.larksuite.com"
|
// parameters via `&`/`#`. The brand→host mapping is owned by core so the
|
||||||
}
|
// open-platform base URL stays a single source of truth.
|
||||||
// PathEscape on appID — it sits in the URL path. QueryEscape on the
|
base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
|
||||||
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope
|
core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
|
||||||
// content must not be able to inject extra query parameters via `&`/`#`.
|
|
||||||
pathID := url.PathEscape(appID)
|
|
||||||
if len(scopes) == 0 {
|
if len(scopes) == 0 {
|
||||||
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID)
|
return base
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ",")))
|
return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
|
||||||
}
|
}
|
||||||
|
|
||||||
func intFromAny(v any) int {
|
func intFromAny(v any) int {
|
||||||
|
|||||||
@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL)
|
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") {
|
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL)
|
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
|
|||||||
name: "ampersand in scope smuggles extra param",
|
name: "ampersand in scope smuggles extra param",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope&evil=injected"},
|
scopes: []string{"scope&evil=injected"},
|
||||||
wantInURL: []string{"q=scope%26evil%3Dinjected"},
|
wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
|
||||||
denyInURL: []string{"q=scope&evil=injected"},
|
denyInURL: []string{"scopes=scope&evil=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in scope splits fragment",
|
name: "hash in scope splits fragment",
|
||||||
appID: "cli_good",
|
appID: "cli_good",
|
||||||
scopes: []string{"scope#fragment"},
|
scopes: []string{"scope#fragment"},
|
||||||
wantInURL: []string{"q=scope%23fragment"},
|
wantInURL: []string{"scopes=scope%23fragment"},
|
||||||
denyInURL: []string{"q=scope#fragment"},
|
denyInURL: []string{"scopes=scope#fragment"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "question mark in appID prematurely opens query",
|
name: "question mark in appID prematurely opens query",
|
||||||
appID: "good?q=injected",
|
appID: "good?q=injected",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%3Fq=injected/auth"},
|
wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
|
||||||
denyInURL: []string{"/app/good?q=injected/auth"},
|
denyInURL: []string{"clientID=good?q=injected"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hash in appID truncates URL",
|
name: "hash in appID truncates URL",
|
||||||
appID: "good#fragment",
|
appID: "good#fragment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%23fragment/auth"},
|
wantInURL: []string{"clientID=good%23fragment"},
|
||||||
denyInURL: []string{"/app/good#fragment/auth"},
|
denyInURL: []string{"clientID=good#fragment"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "slash in appID escapes path segment",
|
name: "slash in appID does not open a new path segment",
|
||||||
appID: "good/extra/segment",
|
appID: "good/extra/segment",
|
||||||
scopes: []string{"docx:document"},
|
scopes: []string{"docx:document"},
|
||||||
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"},
|
wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
|
|||||||
if pe.MissingScopes != nil {
|
if pe.MissingScopes != nil {
|
||||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") {
|
if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL)
|
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
|||||||
// at the app level — re-authenticating cannot fix it. The hint must
|
// at the app level — re-authenticating cannot fix it. The hint must
|
||||||
// point to the developer console regardless of caller identity, or
|
// point to the developer console regardless of caller identity, or
|
||||||
// agents will loop on `auth login` forever.
|
// agents will loop on `auth login` forever.
|
||||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
|
||||||
for _, identity := range []string{"user", "bot", ""} {
|
for _, identity := range []string{"user", "bot", ""} {
|
||||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||||
if !strings.Contains(got, "developer console") {
|
if !strings.Contains(got, "developer console") {
|
||||||
|
|||||||
@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
|
|||||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
var driveCodeMeta = map[int]CodeMeta{
|
var driveCodeMeta = map[int]CodeMeta{
|
||||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||||
|
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||||
|
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||||
|
|||||||
@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
|||||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
|
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
got, ok := LookupCodeMeta(tc.code)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||||
|
}
|
||||||
|
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||||
|
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||||
|
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
code int
|
||||||
|
wantCat errs.Category
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
wantRetry bool
|
||||||
|
}{
|
||||||
|
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||||
|
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||||
|
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||||
|
|||||||
17
internal/errclass/codemeta_wiki.go
Normal file
17
internal/errclass/codemeta_wiki.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errclass
|
||||||
|
|
||||||
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
|
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||||
|
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||||
|
// command-specific recovery guidance at the shortcut layer.
|
||||||
|
var wikiCodeMeta = map[int]CodeMeta{
|
||||||
|
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||||
|
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||||
|
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -78,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
|
|||||||
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
|
||||||
}
|
}
|
||||||
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
for _, match := range credentialURLRE.FindAllString(line, -1) {
|
||||||
if isPlaceholderCredentialURL(match) {
|
if isPlaceholderCredentialURL(file, match) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
|
||||||
}
|
}
|
||||||
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
for _, match := range privateIPv4RE.FindAllString(line, -1) {
|
||||||
|
if !warnForPrivateIPv4(file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
|
||||||
}
|
}
|
||||||
if source == "branch" && automationBranchRE.MatchString(line) {
|
if source == "branch" && automationBranchRE.MatchString(line) {
|
||||||
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
|
|||||||
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
if isBenignTokenField(name) && !credentialShapedValue(value) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return isExplicitCredentialKey(name)
|
return isExplicitCredentialKey(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
|
|||||||
if normalized == "" || credentialShapedIdentifier(normalized) {
|
if normalized == "" || credentialShapedIdentifier(normalized) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if authCredentialTokenKey(key) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return resourceTokenPlaceholderValue(value) ||
|
return resourceTokenPlaceholderValue(value) ||
|
||||||
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
maskedTokenFixturePlaceholderValue(key, normalized) ||
|
||||||
isPlaceholderValue(value) ||
|
isPlaceholderValue(value) ||
|
||||||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
|
|||||||
return stars >= 6 && alnum > 0
|
return stars >= 6 && alnum > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWeakTokenCredentialKey(key string) bool {
|
||||||
|
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return key == "token" ||
|
||||||
|
strings.HasSuffix(key, "_token") ||
|
||||||
|
strings.HasSuffix(key, "-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStrongTokenCredentialKey(key string) bool {
|
||||||
|
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
|
||||||
|
for _, phrase := range [][2]string{
|
||||||
|
{"access", "token"},
|
||||||
|
{"refresh", "token"},
|
||||||
|
{"auth", "token"},
|
||||||
|
{"bearer", "token"},
|
||||||
|
{"session", "token"},
|
||||||
|
{"service", "token"},
|
||||||
|
{"bot", "token"},
|
||||||
|
{"api", "token"},
|
||||||
|
{"secret", "token"},
|
||||||
|
} {
|
||||||
|
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func weakTokenValueLooksCredentialLike(value string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
|
||||||
|
if normalized == "" ||
|
||||||
|
isNonSecretLiteralValue(value) ||
|
||||||
|
isPlaceholderValue(value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
candidate := unwrapCredentialValue(normalized)
|
||||||
|
return credentialShapedIdentifier(candidate) ||
|
||||||
|
highEntropyCredentialValue(candidate) ||
|
||||||
|
commandSubstitutionLooksCredentialLike(normalized) ||
|
||||||
|
(strings.Contains(normalized, "://") &&
|
||||||
|
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unwrapCredentialValue(value string) string {
|
||||||
|
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
|
||||||
|
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
|
||||||
|
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
|
||||||
|
}
|
||||||
|
value = strings.TrimPrefix(value, "$")
|
||||||
|
value = strings.Trim(value, "%")
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func highEntropyCredentialValue(value string) bool {
|
||||||
|
if len(value) < 32 {
|
||||||
|
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 == '-' || r == '.' || r == '=':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
|
||||||
|
}
|
||||||
|
|
||||||
|
func shannonEntropy(value string) float64 {
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
counts := map[rune]int{}
|
||||||
|
for _, r := range value {
|
||||||
|
counts[r]++
|
||||||
|
}
|
||||||
|
var entropy float64
|
||||||
|
length := float64(len([]rune(value)))
|
||||||
|
for _, count := range counts {
|
||||||
|
p := float64(count) / length
|
||||||
|
entropy -= p * log2(p)
|
||||||
|
}
|
||||||
|
return entropy
|
||||||
|
}
|
||||||
|
|
||||||
|
func log2(value float64) float64 {
|
||||||
|
return math.Log(value) / math.Ln2
|
||||||
|
}
|
||||||
|
|
||||||
func authCredentialTokenKey(key string) bool {
|
func authCredentialTokenKey(key string) bool {
|
||||||
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
|
||||||
case "access_token",
|
case "access_token",
|
||||||
|
"api_token",
|
||||||
|
"bot_token",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
|
"secret_token",
|
||||||
"session_token",
|
"session_token",
|
||||||
|
"service_token",
|
||||||
"bearer_token",
|
"bearer_token",
|
||||||
"auth_token",
|
"auth_token",
|
||||||
"authorization_token",
|
"authorization_token",
|
||||||
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
|
|||||||
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
return strings.HasPrefix(strings.TrimSpace(value), "=")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPlaceholderCredentialURL(raw string) bool {
|
func isPlaceholderCredentialURL(file, raw string) bool {
|
||||||
userInfo, ok := credentialURLUserInfo(raw)
|
userInfo, ok := credentialURLUserInfo(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return credentialURLPasswordPlaceholder(password)
|
return credentialURLPasswordPlaceholder(password) ||
|
||||||
|
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
|
||||||
}
|
}
|
||||||
|
|
||||||
func credentialURLPasswordPlaceholder(password string) bool {
|
func credentialURLPasswordPlaceholder(password string) bool {
|
||||||
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
|
|||||||
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func credentialURLPasswordFixture(password string) bool {
|
||||||
|
normalized := strings.ToLower(strings.Trim(password, `"'`))
|
||||||
|
switch normalized {
|
||||||
|
case "p",
|
||||||
|
"pass",
|
||||||
|
"password",
|
||||||
|
"pat_abc",
|
||||||
|
"pw",
|
||||||
|
"s3cret",
|
||||||
|
"secret",
|
||||||
|
"t":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceOrTestFixtureFile(file string) bool {
|
||||||
|
normalized := filepath.ToSlash(file)
|
||||||
|
return sourceCodeFile(normalized) ||
|
||||||
|
strings.HasPrefix(normalized, "testdata/") ||
|
||||||
|
strings.HasPrefix(normalized, "fixtures/") ||
|
||||||
|
strings.Contains(normalized, "/testdata/") ||
|
||||||
|
strings.Contains(normalized, "/fixtures/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func warnForPrivateIPv4(file string) bool {
|
||||||
|
normalized := filepath.ToSlash(file)
|
||||||
|
if sourceOrTestFixtureFile(normalized) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch filepath.Ext(normalized) {
|
||||||
|
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return strings.HasPrefix(normalized, "docs/") ||
|
||||||
|
strings.HasPrefix(normalized, "skills/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func credentialURLUserInfo(raw string) (string, bool) {
|
func credentialURLUserInfo(raw string) (string, bool) {
|
||||||
schemeIdx := strings.Index(raw, "://")
|
schemeIdx := strings.Index(raw, "://")
|
||||||
if schemeIdx < 0 {
|
if schemeIdx < 0 {
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
|
||||||
|
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
|
||||||
|
`proxy := "http://user:pass@10.0.0.1:3128"`,
|
||||||
|
`target := "socks5://admin:secret@172.16.0.1:1080"`,
|
||||||
|
`host := "192.168.0.10"`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_private_ipv4" {
|
||||||
|
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
|
||||||
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
|
||||||
if len(benign) != 0 {
|
if len(benign) != 0 {
|
||||||
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
|
||||||
|
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
|
||||||
|
`proxy := "http://user:pass@proxy:8080"`,
|
||||||
|
`repo := "https://u:t@h/r.git"`,
|
||||||
|
`target := "https://attacker:pw@open.feishu.cn"`,
|
||||||
|
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
|
||||||
|
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_credential_url" {
|
||||||
|
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
|
||||||
|
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
|
||||||
|
`proxy: http://user:pass@proxy:8080`,
|
||||||
|
`repo: https://u:t@h/r.git`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_credential_url" {
|
||||||
|
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
|
||||||
|
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
|
||||||
|
`endpoint: http://10.0.0.1:8080`,
|
||||||
|
`redis: 192.168.1.10:6379`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_private_ipv4" {
|
||||||
|
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
|
||||||
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
|||||||
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
|
||||||
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
|
||||||
"URL=https://<user>:real-secret@example.invalid/path",
|
"URL=https://<user>:real-secret@example.invalid/path",
|
||||||
|
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
|
||||||
}, "\n")+"\n"))
|
}, "\n")+"\n"))
|
||||||
var count int
|
var count int
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if count != 3 {
|
if count != 4 {
|
||||||
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got)
|
t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
|
||||||
|
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
|
||||||
|
`{"token":"img_abc123"}`,
|
||||||
|
`{"token":"img_live_secret"}`,
|
||||||
|
`{"token":"img_prod_key"}`,
|
||||||
|
`token=ab********cd`,
|
||||||
|
`{"image_token":"img_live_secret"}`,
|
||||||
|
`{"data_mail_token":"mail_abc123"}`,
|
||||||
|
`{"whiteboard_token":"board_v3_example"}`,
|
||||||
|
`{"want_token":"token from callback"}`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_generic_credential" {
|
||||||
|
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
|
||||||
|
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
|
||||||
|
stripeToken := "sk_" + "live_1234567890abcdef"
|
||||||
|
randomToken := strings.Join([]string{
|
||||||
|
"a1b2c3d4",
|
||||||
|
"e5f6g7h8",
|
||||||
|
"i9j0k1l2",
|
||||||
|
"m3n4p5q6",
|
||||||
|
}, "")
|
||||||
|
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||||
|
`{"token":"` + githubToken + `"}`,
|
||||||
|
`token=` + stripeToken,
|
||||||
|
`{"image_token":"` + githubToken + `"}`,
|
||||||
|
`{"token":"` + randomToken + `"}`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 4 {
|
||||||
|
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
|
||||||
|
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
|
||||||
|
`{"access_token":"img_abc123"}`,
|
||||||
|
`{"api_token":"img_live_secret"}`,
|
||||||
|
`{"service_token":"ab********cd"}`,
|
||||||
|
`{"bot_token":"board_v3_example"}`,
|
||||||
|
}, "\n")+"\n"))
|
||||||
|
var count int
|
||||||
|
for _, item := range got {
|
||||||
|
if item.Rule == "public_content_generic_credential" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count != 4 {
|
||||||
|
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
|
||||||
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
|
||||||
for _, item := range got {
|
for _, item := range got {
|
||||||
@@ -1052,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
|
func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
|
||||||
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
|
||||||
if !findingRules(got)["public_content_generic_credential"] {
|
for _, item := range got {
|
||||||
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
|
if item.Rule == "public_content_generic_credential" {
|
||||||
|
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||||
// and schema lint.
|
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||||
|
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||||
|
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||||
|
// nothing to resolve; the merged view is the only data such binaries have.
|
||||||
|
func SchemaCatalog() apicatalog.Catalog {
|
||||||
|
if len(EmbeddedServicesTyped()) > 0 {
|
||||||
|
return EmbeddedCatalog()
|
||||||
|
}
|
||||||
|
return RuntimeCatalog()
|
||||||
|
}
|
||||||
|
|||||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||||
|
// restores them (with a full state reset) on cleanup.
|
||||||
|
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resetInit()
|
||||||
|
orig := embeddedMetaJSON
|
||||||
|
embeddedMetaJSON = data
|
||||||
|
t.Cleanup(func() {
|
||||||
|
waitBackgroundRefresh()
|
||||||
|
embeddedMetaJSON = orig
|
||||||
|
resetInit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceEmbedded {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("embedded_svc"); !ok {
|
||||||
|
t.Fatal("expected embedded_svc from embedded metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||||
|
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||||
|
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||||
|
// Init seeds via sync fetch.
|
||||||
|
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(testEnvelopeJSON("remote_svc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
testMetaURL = ts.URL
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceRuntime {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("remote_svc"); !ok {
|
||||||
|
t.Fatal("expected remote_svc from runtime fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
// After a CLI upgrade the embedded data can be fresher than an old
|
||||||
|
// cache; an equal/older cache must not shadow it.
|
||||||
|
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||||
|
// marker, tagged with the given top-level data version and brand.
|
||||||
|
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||||
|
t.Helper()
|
||||||
|
cDir := filepath.Join(dir, "cache")
|
||||||
|
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := MergedRegistry{
|
||||||
|
Version: version,
|
||||||
|
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(reg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||||
|
mData, _ := json.Marshal(cm)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||||
|
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||||
|
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||||
|
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||||
|
t.Helper()
|
||||||
|
embedded, _ := json.Marshal(MergedRegistry{
|
||||||
|
Version: embeddedVer,
|
||||||
|
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||||
|
})
|
||||||
|
swapEmbeddedMeta(t, embedded)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||||
|
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||||
|
InitWithBrand(core.BrandFeishu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, ok := ServiceTyped(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service %q not loaded", name)
|
||||||
|
}
|
||||||
|
return svc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "2.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "2.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "not-a-semver")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||||
|
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||||
|
// plugin builds without compiled meta_data.json still get remote data.
|
||||||
|
initWithCache(t, "0.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// testRegistry returns a minimal MergedRegistry with one service.
|
||||||
|
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||||
|
// so cache overlay passes the version gate in InitWithBrand.
|
||||||
func testRegistry(name string) MergedRegistry {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "test-1.0",
|
Version: "1.0.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "test-1.0" {
|
if reg.Version != "1.0.0" {
|
||||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
|
|||||||
if appID == "" || scope == "" {
|
if appID == "" || scope == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
host := "open.feishu.cn"
|
|
||||||
if brand == core.BrandLark {
|
|
||||||
host = "open.larksuite.com"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"https://%s/page/scope-apply?clientID=%s&scopes=%s",
|
"%s/page/scope-apply?clientID=%s&scopes=%s",
|
||||||
host,
|
core.ResolveOpenBaseURL(brand),
|
||||||
url.QueryEscape(appID),
|
url.QueryEscape(appID),
|
||||||
url.QueryEscape(scope),
|
url.QueryEscape(scope),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
|
InstallPnpm
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,22 +54,32 @@ var (
|
|||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
type DetectResult struct {
|
type DetectResult struct {
|
||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
|
PnpmAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
return d.Method == InstallNpm && d.NpmAvailable
|
switch d.Method {
|
||||||
|
case InstallNpm:
|
||||||
|
return d.NpmAvailable
|
||||||
|
case InstallPnpm:
|
||||||
|
return d.PnpmAvailable
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
switch {
|
||||||
|
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
|
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||||
|
return "installed via pnpm, but pnpm is not available in PATH"
|
||||||
}
|
}
|
||||||
return "not installed via npm"
|
return "not installed via npm or pnpm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
PnpmInstallOverride func(version string) *NpmResult
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -101,17 +113,38 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
backupCreated bool
|
||||||
|
|
||||||
|
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||||
|
// binary was installed cannot change during a single process, so caching is
|
||||||
|
// the correct semantics — and it is required for correctness: the update
|
||||||
|
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||||
|
// skills, so a re-detection at skills time could resolve a now-stale
|
||||||
|
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||||
|
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||||
|
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||||
|
// sequential.
|
||||||
|
detectCache *DetectResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Updater with default (real) behavior.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether
|
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||||
// npm is available for auto-update.
|
// owning package manager is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
return u.DetectOverride()
|
||||||
}
|
}
|
||||||
|
if u.detectCache != nil {
|
||||||
|
return *u.detectCache
|
||||||
|
}
|
||||||
|
result := u.detectInstallMethod()
|
||||||
|
u.detectCache = &result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectInstallMethod performs the real (uncached) detection.
|
||||||
|
func (u *Updater) detectInstallMethod() DetectResult {
|
||||||
exe, err := vfs.Executable()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||||
}
|
}
|
||||||
|
_, npmErr := exec.LookPath("npm")
|
||||||
|
_, pnpmErr := exec.LookPath("pnpm")
|
||||||
|
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFromResolved classifies the resolved binary path into an install
|
||||||
|
// method and records package-manager availability. Split out from
|
||||||
|
// DetectInstallMethod so the classification is unit-testable without touching
|
||||||
|
// the filesystem or PATH.
|
||||||
|
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||||
method := InstallManual
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
method = InstallNpm
|
if containsPnpmMarker(resolved) {
|
||||||
}
|
method = InstallPnpm
|
||||||
|
} else {
|
||||||
npmAvailable := false
|
method = InstallNpm
|
||||||
if method == InstallNpm {
|
|
||||||
if _, err := exec.LookPath("npm"); err == nil {
|
|
||||||
npmAvailable = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||||
return DetectResult{
|
switch method {
|
||||||
Method: method,
|
case InstallNpm:
|
||||||
ResolvedPath: resolved,
|
d.NpmAvailable = npmOnPath
|
||||||
NpmAvailable: npmAvailable,
|
case InstallPnpm:
|
||||||
|
d.PnpmAvailable = pnpmOnPath
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||||
|
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||||
|
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||||
|
// whose resolved path runs through pnpm's home directory (e.g.
|
||||||
|
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||||
|
// followed by "store". Matching only these two shapes (rather than any bare
|
||||||
|
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||||
|
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||||
|
// classification is OS-independent and unit-testable anywhere.
|
||||||
|
func containsPnpmMarker(p string) bool {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == ".pnpm" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||||
|
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||||
|
if u.PnpmInstallOverride != nil {
|
||||||
|
return u.PnpmInstallOverride(version)
|
||||||
|
}
|
||||||
|
r := &NpmResult{}
|
||||||
|
pnpmPath, err := exec.LookPath("pnpm")
|
||||||
|
if err != nil {
|
||||||
|
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||||
|
cmd.Stdout = &r.Stdout
|
||||||
|
cmd.Stderr = &r.Stderr
|
||||||
|
r.Err = cmd.Run()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
return u.runSkillsCommand(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||||
|
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||||
|
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||||
|
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||||
|
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||||
|
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||||
|
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||||
|
// launcher selection is unit-testable on any platform.
|
||||||
|
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||||
|
if method == InstallPnpm && pnpmAvailable {
|
||||||
|
r := args
|
||||||
|
if len(r) > 0 && r[0] == "-y" {
|
||||||
|
r = r[1:]
|
||||||
|
}
|
||||||
|
return "pnpm", append([]string{"dlx"}, r...)
|
||||||
|
}
|
||||||
|
return "npx", args
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
npxPath, err := exec.LookPath("npx")
|
det := u.DetectInstallMethod()
|
||||||
|
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||||
|
binPath, err := exec.LookPath(launcher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainsPnpmMarker(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Classic virtual-store layout (.pnpm segment).
|
||||||
|
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||||
|
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||||
|
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||||
|
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/usr/local/bin/lark-cli", false},
|
||||||
|
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||||
|
// "pnpm" immediately followed by "store".
|
||||||
|
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||||
|
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||||
|
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsPnpmMarker(c.path); got != c.want {
|
||||||
|
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||||
|
u := &Updater{DetectOverride: nil}
|
||||||
|
u.DetectOverride = func() DetectResult {
|
||||||
|
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||||
|
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||||
|
}
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm {
|
||||||
|
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||||
|
}
|
||||||
|
if !got.PnpmAvailable {
|
||||||
|
t.Errorf("PnpmAvailable = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||||
|
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||||
|
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||||
|
}
|
||||||
|
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||||
|
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||||
|
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm available should CanAutoUpdate")
|
||||||
|
}
|
||||||
|
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualReason_Pnpm(t *testing.T) {
|
||||||
|
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||||
|
t.Errorf("pnpm reason = %q", got)
|
||||||
|
}
|
||||||
|
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||||
|
t.Errorf("manual reason = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||||
|
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
got := u.RunPnpmInstall("2.0.0")
|
||||||
|
if got.Err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", got.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||||
|
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||||
|
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInvocation(t *testing.T) {
|
||||||
|
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method InstallMethod
|
||||||
|
pnpmAvailable bool
|
||||||
|
args []string
|
||||||
|
wantLauncher string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||||
|
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||||
|
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||||
|
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||||
|
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||||
|
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||||
|
if gotLauncher != c.wantLauncher {
|
||||||
|
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||||
|
}
|
||||||
|
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||||
|
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||||
|
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||||
|
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||||
|
// stays consistent with what was detected — and reported — before the update.
|
||||||
|
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||||
|
u := New()
|
||||||
|
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||||
|
u.detectCache = &cached
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||||
|
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
|
||||||
cacheTTL = 24 * time.Hour
|
cacheTTL = 24 * time.Hour
|
||||||
fetchTimeout = 5 * time.Second
|
fetchTimeout = 15 * time.Second
|
||||||
stateFile = "update-state.json"
|
stateFile = "update-state.json"
|
||||||
maxBody = 256 << 10 // 256 KB
|
maxBody = 256 << 10 // 256 KB
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.63",
|
"version": "1.0.66",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
|
||||||
|
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should explicitly skip when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
|
||||||
|
! grep -Fq "id: e2e_domains" <<<"$section" ||
|
||||||
|
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
|
||||||
|
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
|
||||||
|
echo "e2e-live should use resolved live_packages instead of always running the full suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
|
||||||
|
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
|
||||||
|
echo "e2e-live should pass dynamic domain output through env before shell use"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$dry_run_section"; then
|
||||||
|
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Build lark-cli/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$section"; then
|
||||||
|
echo "e2e-live should skip building lark-cli when domain mode is skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "permissions:" <<<"$section" ||
|
if ! grep -Fq "permissions:" <<<"$section" ||
|
||||||
! grep -Fq "contents: read" <<<"$section" ||
|
! grep -Fq "contents: read" <<<"$section" ||
|
||||||
! grep -Fq "checks: write" <<<"$section"; then
|
! grep -Fq "checks: write" <<<"$section"; then
|
||||||
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! awk '
|
||||||
|
/^ - name: Configure bot credentials/ { in_step = 1 }
|
||||||
|
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
|
||||||
|
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' <<<"$section"; then
|
||||||
|
echo "e2e-live should only configure bot credentials when domain mode is not skip"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
|
||||||
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
|
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
|
||||||
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
|
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
54
scripts/domain-map.js
Normal file
54
scripts/domain-map.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
|
||||||
|
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
|
||||||
|
|
||||||
|
function normalizeRepoPath(input) {
|
||||||
|
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
|
||||||
|
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
|
||||||
|
.sort((a, b) => b.prefix.length - a.prefix.length);
|
||||||
|
|
||||||
|
function findPathMapping(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.labelDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function e2eDomainsForPath(filePath) {
|
||||||
|
const mapping = findPathMapping(filePath);
|
||||||
|
return mapping ? [...(mapping.e2eDomains || [])] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFullFallback(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkippablePath(filePath) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
const basename = path.posix.basename(normalized);
|
||||||
|
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|
||||||
|
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|
||||||
|
|| (domainMap.skipFilenames || []).includes(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
domainMap,
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
labelDomainsForPath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
};
|
||||||
71
scripts/domain-map.json
Normal file
71
scripts/domain-map.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"pathMappings": [
|
||||||
|
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
|
||||||
|
|
||||||
|
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
|
||||||
|
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
|
||||||
|
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
|
||||||
|
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
|
||||||
|
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
|
||||||
|
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
|
||||||
|
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
|
||||||
|
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
|
||||||
|
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
|
||||||
|
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
|
||||||
|
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
|
||||||
|
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
|
||||||
|
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
|
||||||
|
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
|
||||||
|
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
|
||||||
|
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
|
||||||
|
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
|
||||||
|
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
|
||||||
|
],
|
||||||
|
"fullFallbackPrefixes": [
|
||||||
|
"shortcuts/common/",
|
||||||
|
"cmd/",
|
||||||
|
"internal/",
|
||||||
|
"pkg/",
|
||||||
|
"extension/",
|
||||||
|
"registry/",
|
||||||
|
"go.mod",
|
||||||
|
"go.sum",
|
||||||
|
"Makefile",
|
||||||
|
".github/workflows/",
|
||||||
|
"scripts/"
|
||||||
|
],
|
||||||
|
"skipPrefixes": [
|
||||||
|
"docs/",
|
||||||
|
".changeset/"
|
||||||
|
],
|
||||||
|
"skipSuffixes": [
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".txt",
|
||||||
|
".rst"
|
||||||
|
],
|
||||||
|
"skipFilenames": [
|
||||||
|
"readme.md",
|
||||||
|
"readme.zh.md",
|
||||||
|
"changelog.md",
|
||||||
|
"license",
|
||||||
|
"cla.md"
|
||||||
|
]
|
||||||
|
}
|
||||||
224
scripts/e2e_domains.js
Normal file
224
scripts/e2e_domains.js
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const {
|
||||||
|
e2eDomainsForPath,
|
||||||
|
findPathMapping,
|
||||||
|
isSkippablePath,
|
||||||
|
matchesFullFallback,
|
||||||
|
normalizeRepoPath,
|
||||||
|
} = require("./domain-map");
|
||||||
|
|
||||||
|
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
|
||||||
|
process.chdir(ROOT);
|
||||||
|
|
||||||
|
function execLines(command, args) {
|
||||||
|
return execFileSync(command, args, { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modulePath() {
|
||||||
|
return execLines("go", ["list", "-m"])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rootPackage(moduleName) {
|
||||||
|
return `${moduleName}/tests/cli_e2e`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allLivePackages(moduleName) {
|
||||||
|
return execLines("go", ["list", "./tests/cli_e2e/..."])
|
||||||
|
.filter((pkg) => pkg !== rootPackage(moduleName))
|
||||||
|
.filter((pkg) => !pkg.endsWith("/demo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allDryPackages(moduleName) {
|
||||||
|
return allLivePackages(moduleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainExistsCache = new Map();
|
||||||
|
|
||||||
|
function domainExists(domain) {
|
||||||
|
if (domainExistsCache.has(domain)) {
|
||||||
|
return domainExistsCache.get(domain);
|
||||||
|
}
|
||||||
|
let exists = false;
|
||||||
|
try {
|
||||||
|
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
|
||||||
|
exists = true;
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
domainExistsCache.set(domain, exists);
|
||||||
|
return exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChangedFiles() {
|
||||||
|
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
|
||||||
|
if (changedFilesPath) {
|
||||||
|
return fs.readFileSync(changedFilesPath, "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(normalizeRepoPath)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRef = process.env.GITHUB_BASE_REF || "main";
|
||||||
|
try {
|
||||||
|
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
|
||||||
|
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDomain(domains, domain) {
|
||||||
|
if (domain && domainExists(domain)) {
|
||||||
|
domains.add(domain);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyPath(filePath, domains) {
|
||||||
|
const normalized = normalizeRepoPath(filePath);
|
||||||
|
if (!normalized) return { matched: false };
|
||||||
|
|
||||||
|
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
|
||||||
|
if (e2eMatch) {
|
||||||
|
const domain = e2eMatch[1];
|
||||||
|
if (domain === "demo") return { matched: false };
|
||||||
|
if (domainExists(domain)) {
|
||||||
|
addDomain(domains, domain);
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("tests/cli_e2e/")) {
|
||||||
|
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesFullFallback(normalized)) {
|
||||||
|
return { fullReason: `shared/runtime path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedDomains = e2eDomainsForPath(normalized);
|
||||||
|
if (mappedDomains.length > 0) {
|
||||||
|
const missingDomains = [];
|
||||||
|
for (const domain of mappedDomains) {
|
||||||
|
if (!addDomain(domains, domain)) missingDomains.push(domain);
|
||||||
|
}
|
||||||
|
if (missingDomains.length > 0) {
|
||||||
|
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
|
||||||
|
}
|
||||||
|
return { matched: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (findPathMapping(normalized)) {
|
||||||
|
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
|
||||||
|
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSkippablePath(normalized)) return { matched: false };
|
||||||
|
|
||||||
|
return { fullReason: `unclassified path changed: ${normalized}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDomains(changedFiles) {
|
||||||
|
const moduleName = modulePath();
|
||||||
|
const rootDryPackage = rootPackage(moduleName);
|
||||||
|
if (changedFiles === null) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: "non-pull_request run or unavailable diff",
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domains = new Set();
|
||||||
|
let matchedRelevant = false;
|
||||||
|
let fullReason = "";
|
||||||
|
|
||||||
|
for (const file of changedFiles) {
|
||||||
|
const result = classifyPath(file, domains);
|
||||||
|
if (result.matched) matchedRelevant = true;
|
||||||
|
if (result.fullReason && !fullReason) fullReason = result.fullReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullReason) {
|
||||||
|
return {
|
||||||
|
mode: "full",
|
||||||
|
reason: fullReason,
|
||||||
|
domains: ["all"],
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: allDryPackages(moduleName),
|
||||||
|
livePackages: allLivePackages(moduleName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedRelevant && domains.size > 0) {
|
||||||
|
const sortedDomains = [...domains].sort();
|
||||||
|
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
|
||||||
|
return {
|
||||||
|
mode: "subset",
|
||||||
|
reason: "business domain changes",
|
||||||
|
domains: sortedDomains,
|
||||||
|
dryRootPackage: rootDryPackage,
|
||||||
|
dryPackages: packages,
|
||||||
|
livePackages: packages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: "skip",
|
||||||
|
reason: "docs-only or no live CLI E2E impact",
|
||||||
|
domains: [],
|
||||||
|
dryRootPackage: "",
|
||||||
|
dryPackages: [],
|
||||||
|
livePackages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(resolved) {
|
||||||
|
const values = {
|
||||||
|
mode: resolved.mode,
|
||||||
|
reason: resolved.reason,
|
||||||
|
domains: resolved.domains.join(","),
|
||||||
|
dry_root_package: resolved.dryRootPackage,
|
||||||
|
dry_packages: resolved.dryPackages.join(" "),
|
||||||
|
live_packages: resolved.livePackages.join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
|
||||||
|
console.log(lines.join("\n"));
|
||||||
|
|
||||||
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
|
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
emit(resolveDomains(readChangedFiles()));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
classifyPath,
|
||||||
|
readChangedFiles,
|
||||||
|
resolveDomains,
|
||||||
|
};
|
||||||
94
scripts/e2e_domains.test.js
Normal file
94
scripts/e2e_domains.test.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const scriptPath = path.join(__dirname, "e2e_domains.js");
|
||||||
|
|
||||||
|
function parseOutput(raw) {
|
||||||
|
const result = {};
|
||||||
|
for (const line of raw.trim().split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf("=");
|
||||||
|
if (idx === -1) continue;
|
||||||
|
result[line.slice(0, idx)] = line.slice(idx + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDomains(files) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
|
||||||
|
const file = path.join(dir, "changed.txt");
|
||||||
|
fs.writeFileSync(file, `${files.join("\n")}\n`);
|
||||||
|
try {
|
||||||
|
return parseOutput(execFileSync(process.execPath, [scriptPath], {
|
||||||
|
cwd: path.join(__dirname, ".."),
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("maps shortcut changes to one business domain package", () => {
|
||||||
|
const output = runDomains(["shortcuts/im/messages/send.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "im");
|
||||||
|
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
|
||||||
|
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps doc shortcuts to docs package", () => {
|
||||||
|
const output = runDomains(["shortcuts/doc/update.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "docs");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps direct e2e domain package changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "drive");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for shared e2e harness changes", () => {
|
||||||
|
const output = runDomains(["tests/cli_e2e/core.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared CLI E2E harness changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full for runtime changes", () => {
|
||||||
|
const output = runDomains(["cmd/root.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.equal(output.domains, "all");
|
||||||
|
assert.match(output.reason, /shared\/runtime path changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips docs-only changes", () => {
|
||||||
|
const output = runDomains(["docs/usage.md", "README.md"]);
|
||||||
|
assert.equal(output.mode, "skip");
|
||||||
|
assert.equal(output.domains, "");
|
||||||
|
assert.equal(output.dry_root_package, "");
|
||||||
|
assert.equal(output.live_packages, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses shared map for skill domain changes", () => {
|
||||||
|
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
|
||||||
|
assert.equal(output.mode, "subset");
|
||||||
|
assert.equal(output.domains, "sheets");
|
||||||
|
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to full when a mapped path has no e2e package", () => {
|
||||||
|
const output = runDomains(["shortcuts/whiteboard/export.go"]);
|
||||||
|
assert.equal(output.mode, "full");
|
||||||
|
assert.match(output.reason, /unmapped CLI E2E domain path/);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const { labelDomainsForPath } = require("../domain-map");
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants & Configuration
|
// Constants & Configuration
|
||||||
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
|
|||||||
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
|
||||||
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
|
||||||
|
|
||||||
// CODEOWNERS-based path to domain label mapping
|
|
||||||
// Maps shortcuts and skills paths to business domain labels
|
|
||||||
const PATH_TO_DOMAIN_MAP = {
|
|
||||||
// shortcuts
|
|
||||||
"shortcuts/im/": "im",
|
|
||||||
"shortcuts/vc/": "vc",
|
|
||||||
"shortcuts/calendar/": "calendar",
|
|
||||||
"shortcuts/doc/": "ccm",
|
|
||||||
"shortcuts/sheets/": "ccm",
|
|
||||||
"shortcuts/drive/": "ccm",
|
|
||||||
"shortcuts/wiki/": "ccm",
|
|
||||||
"shortcuts/base/": "base",
|
|
||||||
"shortcuts/mail/": "mail",
|
|
||||||
"shortcuts/task/": "task",
|
|
||||||
"shortcuts/contact/": "contact",
|
|
||||||
// skills
|
|
||||||
"skills/lark-im/": "im",
|
|
||||||
"skills/lark-vc/": "vc",
|
|
||||||
"skills/lark-doc/": "ccm",
|
|
||||||
"skills/lark-wiki/": "ccm",
|
|
||||||
"skills/lark-base/": "base",
|
|
||||||
"skills/lark-mail/": "mail",
|
|
||||||
"skills/lark-calendar/": "calendar",
|
|
||||||
"skills/lark-task/": "task",
|
|
||||||
"skills/lark-contact/": "contact",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
|
||||||
|
|
||||||
const CLASS_STANDARDS = {
|
const CLASS_STANDARDS = {
|
||||||
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
|
|||||||
|
|
||||||
// Get business domain label based on CODEOWNERS path mapping
|
// Get business domain label based on CODEOWNERS path mapping
|
||||||
function getBusinessDomain(filePath) {
|
function getBusinessDomain(filePath) {
|
||||||
const normalized = normalizePath(filePath);
|
return labelDomainsForPath(filePath)[0] || "";
|
||||||
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
|
|
||||||
if (normalized.startsWith(prefix)) {
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function detectNewShortcutDomain(files) {
|
async function detectNewShortcutDomain(files) {
|
||||||
|
|||||||
@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|||||||
script="$repo_root/scripts/resolve-changed-from.sh"
|
script="$repo_root/scripts/resolve-changed-from.sh"
|
||||||
|
|
||||||
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
|
||||||
|
cleanup_tmp() {
|
||||||
|
local attempt
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
rm -rf "$tmp" && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_tmp EXIT
|
||||||
mkdir -p "$tmp"
|
mkdir -p "$tmp"
|
||||||
|
|
||||||
git_init() {
|
git_init() {
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
|||||||
return attendees, nil
|
return attendees, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||||
|
for _, attendee := range attendees {
|
||||||
|
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
||||||
|
if err == nil || !attendeesIncludeRoom(attendees) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
||||||
|
}
|
||||||
|
|
||||||
var CalendarCreate = common.Shortcut{
|
var CalendarCreate = common.Shortcut{
|
||||||
Service: "calendar",
|
Service: "calendar",
|
||||||
Command: "+create",
|
Command: "+create",
|
||||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"need_notification": true,
|
"need_notification": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = guideApprovalRoomReasonError(err, attendees)
|
||||||
// Rollback: delete the event
|
// Rollback: delete the event
|
||||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"start": startStr,
|
"start": startStr,
|
||||||
"end": endStr,
|
"end": endStr,
|
||||||
}
|
}
|
||||||
|
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
|
||||||
|
resultData["recurrence"] = recurrence
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||||
var rows []map[string]interface{}
|
var rows []map[string]interface{}
|
||||||
|
|||||||
279
shortcuts/calendar/calendar_get.go
Normal file
279
shortcuts/calendar/calendar_get.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
//
|
||||||
|
// calendar +get — get a single calendar event detail by calendar_id and event_id
|
||||||
|
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// calendarEventTime mirrors start_time / end_time in the API response.
|
||||||
|
type calendarEventTime struct {
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Timestamp string `json:"timestamp,omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventVChat mirrors the vchat block in the API response.
|
||||||
|
type calendarEventVChat struct {
|
||||||
|
VCType string `json:"vc_type,omitempty"`
|
||||||
|
IconType string `json:"icon_type,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
MeetingURL string `json:"meeting_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventLocation mirrors the location block in the API response.
|
||||||
|
type calendarEventLocation struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventReminder mirrors a reminder entry.
|
||||||
|
type calendarEventReminder struct {
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventOrganizer mirrors event_organizer.
|
||||||
|
type calendarEventOrganizer struct {
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventAttachment mirrors a single attachment entry.
|
||||||
|
type calendarEventAttachment struct {
|
||||||
|
FileToken string `json:"file_token,omitempty"`
|
||||||
|
FileSize string `json:"file_size,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
|
||||||
|
type calendarEventCheckInTime struct {
|
||||||
|
TimeType string `json:"time_type,omitempty"`
|
||||||
|
Duration int `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEventCheckIn mirrors event_check_in.
|
||||||
|
type calendarEventCheckIn struct {
|
||||||
|
EnableCheckIn bool `json:"enable_check_in"`
|
||||||
|
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
|
||||||
|
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
|
||||||
|
NeedNotifyAttendees bool `json:"need_notify_attendees"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarEvent mirrors the event object inside the API response.
|
||||||
|
type calendarEvent struct {
|
||||||
|
EventID string `json:"event_id,omitempty"`
|
||||||
|
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
StartTime *calendarEventTime `json:"start_time,omitempty"`
|
||||||
|
EndTime *calendarEventTime `json:"end_time,omitempty"`
|
||||||
|
VChat *calendarEventVChat `json:"vchat,omitempty"`
|
||||||
|
Visibility string `json:"visibility,omitempty"`
|
||||||
|
AttendeeAbility string `json:"attendee_ability,omitempty"`
|
||||||
|
FreeBusyStatus string `json:"free_busy_status,omitempty"`
|
||||||
|
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
|
||||||
|
Location *calendarEventLocation `json:"location,omitempty"`
|
||||||
|
Color int `json:"color,omitempty"`
|
||||||
|
Reminders []calendarEventReminder `json:"reminders,omitempty"`
|
||||||
|
Recurrence string `json:"recurrence,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
IsException bool `json:"is_exception,omitempty"`
|
||||||
|
RecurringEventID string `json:"recurring_event_id,omitempty"`
|
||||||
|
CreateTime string `json:"create_time,omitempty"`
|
||||||
|
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
|
||||||
|
AppLink string `json:"app_link,omitempty"`
|
||||||
|
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
|
||||||
|
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
|
||||||
|
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
|
||||||
|
rawEvent, ok := data["event"]
|
||||||
|
if !ok || rawEvent == nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rawEvent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var event calendarEvent
|
||||||
|
if err := json.Unmarshal(raw, &event); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCalendarEventOutput converts the typed event into the output map and
|
||||||
|
// applies the four transformation rules:
|
||||||
|
// 1. create_time -> RFC3339
|
||||||
|
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
|
||||||
|
// 3. flatten event into the top-level result
|
||||||
|
// 4. when status != "cancelled", drop status (and adjust all-day end date)
|
||||||
|
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
|
||||||
|
raw, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
|
||||||
|
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(startMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
|
||||||
|
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
|
||||||
|
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||||
|
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
|
||||||
|
delete(endMap, "timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
|
||||||
|
if dt, _ := endMap["datetime"].(string); dt == "" {
|
||||||
|
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
|
||||||
|
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if status, _ := out["status"].(string); status != "cancelled" {
|
||||||
|
delete(out, "status")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarGet gets a single calendar event detail.
|
||||||
|
var CalendarGet = common.Shortcut{
|
||||||
|
Service: "calendar",
|
||||||
|
Command: "+get",
|
||||||
|
Description: "Get a single calendar event detail by calendar-id and event-id",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"calendar:calendar.event:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
|
||||||
|
{Name: "event-id", Desc: "event ID", Required: true},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, flag := range []string{"calendar-id", "event-id"} {
|
||||||
|
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
|
||||||
|
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
if eventId == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
d := common.NewDryRunAPI()
|
||||||
|
switch calendarId {
|
||||||
|
case "":
|
||||||
|
d.Desc("(calendar-id omitted) Will use primary calendar")
|
||||||
|
calendarId = "<primary>"
|
||||||
|
case "primary":
|
||||||
|
calendarId = "<primary>"
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
return d.
|
||||||
|
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
|
||||||
|
Set("calendar_id", calendarId).
|
||||||
|
Set("event_id", eventId)
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
|
||||||
|
if calendarId == "" {
|
||||||
|
calendarId = PrimaryCalendarIDStr
|
||||||
|
}
|
||||||
|
eventId := strings.TrimSpace(runtime.Str("event-id"))
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped("GET",
|
||||||
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
|
||||||
|
validate.EncodePathSegment(calendarId),
|
||||||
|
validate.EncodePathSegment(eventId)),
|
||||||
|
nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
event, err := parseCalendarEvent(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := buildCalendarEventOutput(event)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||||
|
summary, _ := out["summary"].(string)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "(untitled)"
|
||||||
|
}
|
||||||
|
startMap, _ := out["start_time"].(map[string]interface{})
|
||||||
|
endMap, _ := out["end_time"].(map[string]interface{})
|
||||||
|
startStr, _ := startMap["datetime"].(string)
|
||||||
|
if startStr == "" {
|
||||||
|
startStr, _ = startMap["date"].(string)
|
||||||
|
}
|
||||||
|
endStr, _ := endMap["datetime"].(string)
|
||||||
|
if endStr == "" {
|
||||||
|
endStr, _ = endMap["date"].(string)
|
||||||
|
}
|
||||||
|
eventIdOut, _ := out["event_id"].(string)
|
||||||
|
freeBusyStatus, _ := out["free_busy_status"].(string)
|
||||||
|
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
|
||||||
|
row := map[string]interface{}{
|
||||||
|
"event_id": eventIdOut,
|
||||||
|
"summary": summary,
|
||||||
|
"start": startStr,
|
||||||
|
"end": endStr,
|
||||||
|
"free_busy_status": freeBusyStatus,
|
||||||
|
"self_rsvp_status": selfRsvpStatus,
|
||||||
|
}
|
||||||
|
output.PrintTable(w, []map[string]interface{}{row})
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_approval_room",
|
||||||
|
"summary": "Approval Room",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/events/evt_approval_room/attendees",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": codeInvalidParamsWithDetail,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"details": []interface{}{
|
||||||
|
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE",
|
||||||
|
URL: "/events/evt_approval_room",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--summary", "Approval Room",
|
||||||
|
"--start", "2025-03-21T00:00:00+08:00",
|
||||||
|
"--end", "2025-03-21T01:00:00+08:00",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--attendee-ids", "omm_room1",
|
||||||
|
"--as", "user",
|
||||||
|
}, f, nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf returned !ok for %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI {
|
||||||
|
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||||
|
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||||
|
}
|
||||||
|
if p.Code != codeInvalidParamsWithDetail {
|
||||||
|
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
||||||
|
if !strings.Contains(p.Hint, want) {
|
||||||
|
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||||
// primary error stays the add failure (classification preserved) and the Hint
|
// primary error stays the add failure (classification preserved) and the Hint
|
||||||
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||||
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
|
|||||||
// Shortcuts() registration test
|
// Shortcuts() registration test
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestShortcuts_Returns9(t *testing.T) {
|
func TestShortcuts_Returns10(t *testing.T) {
|
||||||
shortcuts := Shortcuts()
|
shortcuts := Shortcuts()
|
||||||
if len(shortcuts) != 9 {
|
if len(shortcuts) != 10 {
|
||||||
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts))
|
t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
|
||||||
}
|
}
|
||||||
|
|
||||||
names := map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, s := range shortcuts {
|
for _, s := range shortcuts {
|
||||||
names[s.Command] = true
|
names[s.Command] = true
|
||||||
}
|
}
|
||||||
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} {
|
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
|
||||||
if !names[want] {
|
if !names[want] {
|
||||||
t.Errorf("missing shortcut %s", want)
|
t.Errorf("missing shortcut %s", want)
|
||||||
}
|
}
|
||||||
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
|
|||||||
t.Errorf("param=%q, want --timezone", ve.Param)
|
t.Errorf("param=%q, want --timezone", ve.Param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CalendarGet tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_001",
|
||||||
|
"summary": "Daily Sync",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742515200",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"end_time": map[string]interface{}{
|
||||||
|
"timestamp": "1742518800",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_001",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// Expect flattened — fields appear directly under "data", not under "data.event"
|
||||||
|
if strings.Contains(out, "\"event\": {") {
|
||||||
|
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
|
||||||
|
t.Errorf("expected event_id in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// status=confirmed should be dropped
|
||||||
|
if strings.Contains(out, "\"status\": \"confirmed\"") {
|
||||||
|
t.Errorf("status should be dropped when not cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
// timestamp must be replaced with datetime
|
||||||
|
if strings.Contains(out, "\"timestamp\":") {
|
||||||
|
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "\"datetime\":") {
|
||||||
|
t.Errorf("expected datetime in output, got: %s", out)
|
||||||
|
}
|
||||||
|
// create_time must be RFC3339 (contain 'T' and timezone)
|
||||||
|
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
|
||||||
|
t.Errorf("expected RFC3339 create_time, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_002",
|
||||||
|
"summary": "Cancelled Meeting",
|
||||||
|
"create_time": "1602504000",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
"status": "cancelled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_002",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "\"status\": \"cancelled\"") {
|
||||||
|
t.Errorf("status should be preserved when cancelled, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_003",
|
||||||
|
"summary": "All-day",
|
||||||
|
"start_time": map[string]interface{}{"date": "2025-03-21"},
|
||||||
|
"end_time": map[string]interface{}{"date": "2025-03-22"},
|
||||||
|
"status": "confirmed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_003",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
|
||||||
|
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
|
||||||
|
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_EmptyEventID_Typed(t *testing.T) {
|
||||||
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--event-id", " ",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error for empty event-id")
|
||||||
|
}
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
if ve.Param != "--event-id" {
|
||||||
|
t.Errorf("param=%q, want --event-id", ve.Param)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "success",
|
||||||
|
"data": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarGet, []string{
|
||||||
|
"+get",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--event-id", "evt_404",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error when event field is missing")
|
||||||
|
}
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(err, &ie) {
|
||||||
|
t.Fatalf("want *errs.InternalError, got %T", err)
|
||||||
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
|
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
|
|||||||
CalendarSuggestion,
|
CalendarSuggestion,
|
||||||
CalendarMeeting,
|
CalendarMeeting,
|
||||||
CalendarSearchEvent,
|
CalendarSearchEvent,
|
||||||
|
CalendarGet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
|
||||||
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true},
|
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
|
||||||
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true},
|
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
|
||||||
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
{Name: "doc-id", Desc: "document ID (for drive_route_token)"},
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
|||||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type docsHistoryListSpec struct {
|
||||||
|
Doc documentRef
|
||||||
|
PageSize int
|
||||||
|
PageToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
type docsHistoryRevertSpec struct {
|
||||||
|
Doc documentRef
|
||||||
|
HistoryVersionID string
|
||||||
|
WaitTimeoutMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
type docsHistoryRevertStatusSpec struct {
|
||||||
|
Doc documentRef
|
||||||
|
TaskID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||||
|
ref, err := parseDocumentRef(raw)
|
||||||
|
if err != nil {
|
||||||
|
return documentRef{}, err
|
||||||
|
}
|
||||||
|
if ref.Kind == "doc" {
|
||||||
|
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsHistoryPageSize(pageSize int) error {
|
||||||
|
if pageSize < 1 || pageSize > 20 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||||
|
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||||
|
}
|
||||||
|
if version <= 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||||
|
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"page_size": spec.PageSize,
|
||||||
|
}
|
||||||
|
if spec.PageToken != "" {
|
||||||
|
params["page_token"] = spec.PageToken
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"history_version_id": spec.HistoryVersionID,
|
||||||
|
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"task_id": spec.TaskID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||||
|
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
var DocsHistoryList = common.Shortcut{
|
||||||
|
Service: "docs",
|
||||||
|
Command: "+history-list",
|
||||||
|
Description: "List Lark document history versions",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"docx:document:readonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
PostMount: installDocsShortcutHelp("+history-list"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
|
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||||
|
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||||
|
spec := docsHistoryListSpec{
|
||||||
|
Doc: ref,
|
||||||
|
PageSize: runtime.Int("page-size"),
|
||||||
|
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
Desc("OpenAPI: list document history versions").
|
||||||
|
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||||
|
Set("document_id", spec.Doc.Token).
|
||||||
|
Params(docsHistoryListParams(spec))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||||
|
spec := docsHistoryListSpec{
|
||||||
|
Doc: ref,
|
||||||
|
PageSize: runtime.Int("page-size"),
|
||||||
|
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped(
|
||||||
|
http.MethodGet,
|
||||||
|
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||||
|
docsHistoryListParams(spec),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime.OutRaw(data, nil)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DocsHistoryRevert = common.Shortcut{
|
||||||
|
Service: "docs",
|
||||||
|
Command: "+history-revert",
|
||||||
|
Description: "Revert a Lark document to a historical version",
|
||||||
|
Risk: "write",
|
||||||
|
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
|
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||||
|
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||||
|
spec := docsHistoryRevertSpec{
|
||||||
|
Doc: ref,
|
||||||
|
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||||
|
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
Desc("OpenAPI: revert document history").
|
||||||
|
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||||
|
Set("document_id", spec.Doc.Token).
|
||||||
|
Body(docsHistoryRevertBody(spec))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||||
|
spec := docsHistoryRevertSpec{
|
||||||
|
Doc: ref,
|
||||||
|
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||||
|
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped(
|
||||||
|
http.MethodPost,
|
||||||
|
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||||
|
nil,
|
||||||
|
docsHistoryRevertBody(spec),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime.OutRaw(data, nil)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DocsHistoryRevertStatus = common.Shortcut{
|
||||||
|
Service: "docs",
|
||||||
|
Command: "+history-revert-status",
|
||||||
|
Description: "Get Lark document history revert task status",
|
||||||
|
Risk: "read",
|
||||||
|
Scopes: []string{"docx:document:readonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||||
|
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||||
|
spec := docsHistoryRevertStatusSpec{
|
||||||
|
Doc: ref,
|
||||||
|
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||||
|
}
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
Desc("OpenAPI: get document history revert status").
|
||||||
|
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||||
|
Set("document_id", spec.Doc.Token).
|
||||||
|
Params(docsHistoryStatusParams(spec))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||||
|
spec := docsHistoryRevertStatusSpec{
|
||||||
|
Doc: ref,
|
||||||
|
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := runtime.CallAPITyped(
|
||||||
|
http.MethodGet,
|
||||||
|
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||||
|
docsHistoryStatusParams(spec),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
runtime.OutRaw(data, nil)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
340
shortcuts/doc/docs_history_test.go
Normal file
340
shortcuts/doc/docs_history_test.go
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package doc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"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 TestDocsHistoryValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
shortcut common.Shortcut
|
||||||
|
args []string
|
||||||
|
param string
|
||||||
|
category errs.Category
|
||||||
|
subtype errs.Subtype
|
||||||
|
wantCause bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "list rejects legacy doc URL",
|
||||||
|
shortcut: DocsHistoryList,
|
||||||
|
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||||
|
param: "--doc",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list rejects invalid page size",
|
||||||
|
shortcut: DocsHistoryList,
|
||||||
|
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||||
|
param: "--page-size",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revert rejects non-numeric history version id",
|
||||||
|
shortcut: DocsHistoryRevert,
|
||||||
|
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||||
|
param: "--history-version-id",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
wantCause: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revert rejects non-positive history version id",
|
||||||
|
shortcut: DocsHistoryRevert,
|
||||||
|
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||||
|
param: "--history-version-id",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revert rejects invalid wait timeout",
|
||||||
|
shortcut: DocsHistoryRevert,
|
||||||
|
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||||
|
param: "--wait-timeout-ms",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status rejects empty task id",
|
||||||
|
shortcut: DocsHistoryRevertStatus,
|
||||||
|
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||||
|
param: "--task-id",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||||
|
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("error is not typed: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if problem.Category != tt.category {
|
||||||
|
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||||
|
}
|
||||||
|
if problem.Subtype != tt.subtype {
|
||||||
|
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != tt.param {
|
||||||
|
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||||
|
}
|
||||||
|
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||||
|
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsHistoryDryRun(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||||
|
"doc": "doxcnHistoryDryRun",
|
||||||
|
"page-size": "5",
|
||||||
|
"page-token": "page_token_1",
|
||||||
|
})
|
||||||
|
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||||
|
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||||
|
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||||
|
t.Fatalf("list page_size = %d, want 5", got)
|
||||||
|
}
|
||||||
|
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||||
|
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||||
|
"doc": "doxcnHistoryDryRun",
|
||||||
|
"history-version-id": "42",
|
||||||
|
"wait-timeout-ms": "30000",
|
||||||
|
})
|
||||||
|
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||||
|
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||||
|
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||||
|
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||||
|
}
|
||||||
|
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||||
|
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||||
|
"doc": "doxcnHistoryDryRun",
|
||||||
|
"task-id": "task_1",
|
||||||
|
})
|
||||||
|
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||||
|
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||||
|
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||||
|
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"entries": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"revision_id": float64(42),
|
||||||
|
"history_version_id": "11",
|
||||||
|
"edit_time": "1780000000",
|
||||||
|
"type": float64(1),
|
||||||
|
"editor_ids": []interface{}{"ou_1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"has_more": true,
|
||||||
|
"page_token": "page_token_2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||||
|
"+history-list",
|
||||||
|
"--doc", "doxcnHistory",
|
||||||
|
"--page-size", "5",
|
||||||
|
"--page-token", "page_token_1",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||||
|
if got := data["page_token"]; got != "page_token_2" {
|
||||||
|
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||||
|
}
|
||||||
|
entries, _ := data["entries"].([]interface{})
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"task_id": "task_1",
|
||||||
|
"status": "running",
|
||||||
|
"history_version_id": "42",
|
||||||
|
"poll_after_ms": float64(10000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
|
||||||
|
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||||
|
"+history-revert",
|
||||||
|
"--doc", "doxcnHistory",
|
||||||
|
"--history-version-id", "42",
|
||||||
|
"--wait-timeout-ms", "0",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||||
|
}
|
||||||
|
if got := body["history_version_id"]; got != "42" {
|
||||||
|
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||||
|
}
|
||||||
|
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||||
|
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||||
|
if got := data["task_id"]; got != "task_1" {
|
||||||
|
t.Fatalf("task_id = %#v, want task_1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"status": "partial_failed",
|
||||||
|
"history_version_id": "11",
|
||||||
|
"failed_block_tokens": []interface{}{"blk_1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||||
|
"+history-revert-status",
|
||||||
|
"--doc", "doxcnHistory",
|
||||||
|
"--task-id", "task_1",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||||
|
if got := data["status"]; got != "partial_failed" {
|
||||||
|
t.Fatalf("status = %#v, want partial_failed", got)
|
||||||
|
}
|
||||||
|
if got := data["history_version_id"]; got != "11" {
|
||||||
|
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||||
|
}
|
||||||
|
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||||
|
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||||
|
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := &cobra.Command{Use: shortcut.Command}
|
||||||
|
for _, flag := range shortcut.Flags {
|
||||||
|
switch flag.Type {
|
||||||
|
case "int":
|
||||||
|
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||||
|
default:
|
||||||
|
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, value := range values {
|
||||||
|
if err := cmd.Flags().Set(name, value); err != nil {
|
||||||
|
t.Fatalf("set --%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var envelope map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
|
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||||
|
}
|
||||||
|
data, _ := envelope["data"].(map[string]interface{})
|
||||||
|
if data == nil {
|
||||||
|
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
|||||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||||
case "update":
|
case "update":
|
||||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||||
|
case "history-list", "history-revert", "history-revert-status":
|
||||||
|
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||||
default:
|
default:
|
||||||
return docsSkillReadCommand
|
return docsSkillReadCommand
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
|||||||
return "lark-cli docs +fetch --help"
|
return "lark-cli docs +fetch --help"
|
||||||
case "update":
|
case "update":
|
||||||
return "lark-cli docs +update --help"
|
return "lark-cli docs +update --help"
|
||||||
|
case "history-list":
|
||||||
|
return "lark-cli docs +history-list --help"
|
||||||
|
case "history-revert":
|
||||||
|
return "lark-cli docs +history-revert --help"
|
||||||
|
case "history-revert-status":
|
||||||
|
return "lark-cli docs +history-revert-status --help"
|
||||||
default:
|
default:
|
||||||
return "lark-cli docs --help"
|
return "lark-cli docs --help"
|
||||||
}
|
}
|
||||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
|||||||
DocsCreate,
|
DocsCreate,
|
||||||
DocsFetch,
|
DocsFetch,
|
||||||
DocsUpdate,
|
DocsUpdate,
|
||||||
|
DocsHistoryList,
|
||||||
|
DocsHistoryRevert,
|
||||||
|
DocsHistoryRevertStatus,
|
||||||
DocMediaInsert,
|
DocMediaInsert,
|
||||||
DocMediaUpload,
|
DocMediaUpload,
|
||||||
DocMediaPreview,
|
DocMediaPreview,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ const (
|
|||||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||||
|
|
||||||
|
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
|
||||||
)
|
)
|
||||||
|
|
||||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||||
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
|
|||||||
"pptx": {"slides"},
|
"pptx": {"slides"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||||
|
|
||||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||||
type driveImportSpec struct {
|
type driveImportSpec struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
return status, true, nil
|
return status, true, nil
|
||||||
}
|
}
|
||||||
if status.Failed() {
|
if status.Failed() {
|
||||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
return status, false, driveImportFailureError(status)
|
||||||
if msg == "" {
|
|
||||||
msg = status.StatusLabel()
|
|
||||||
}
|
|
||||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hadSuccessfulPoll && lastErr != nil {
|
if !hadSuccessfulPoll && lastErr != nil {
|
||||||
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
|
|
||||||
return lastStatus, false, nil
|
return lastStatus, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func driveImportFailureError(status driveImportStatus) *errs.APIError {
|
||||||
|
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||||
|
if msg == "" {
|
||||||
|
msg = status.StatusLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||||
|
if code, ok := driveImportConcurrentOperationCode(msg); ok {
|
||||||
|
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
|
||||||
|
}
|
||||||
|
return apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveImportConcurrentOperationCode(msg string) (int, bool) {
|
||||||
|
for _, code := range driveImportConcurrentOperationCodes {
|
||||||
|
codeText := strconv.Itoa(code)
|
||||||
|
for idx := strings.Index(msg, codeText); idx >= 0; {
|
||||||
|
end := idx + len(codeText)
|
||||||
|
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
|
||||||
|
return code, true
|
||||||
|
}
|
||||||
|
|
||||||
|
nextStart := idx + 1
|
||||||
|
next := strings.Index(msg[nextStart:], codeText)
|
||||||
|
if next < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
idx = nextStart + next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIDigit(ch byte) bool {
|
||||||
|
return ch >= '0' && ch <= '9'
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, code := range driveImportConcurrentOperationCodes {
|
||||||
|
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := driveImportFailureError(driveImportStatus{
|
||||||
|
JobStatus: 3,
|
||||||
|
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
|
||||||
|
})
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryAPI {
|
||||||
|
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeServerError {
|
||||||
|
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
|
||||||
|
}
|
||||||
|
if problem.Code != code {
|
||||||
|
t.Fatalf("code = %d, want %d", problem.Code, code)
|
||||||
|
}
|
||||||
|
if !problem.Retryable {
|
||||||
|
t.Fatal("expected retryable error")
|
||||||
|
}
|
||||||
|
if problem.Hint != driveImportConcurrentOperationHint {
|
||||||
|
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ordinary failure",
|
||||||
|
msg: "unsupported conversion",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "longer numeric code containing known code",
|
||||||
|
msg: "call CreateObjNode return error code, code: 12321401012, message:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := driveImportFailureError(driveImportStatus{
|
||||||
|
JobStatus: 3,
|
||||||
|
JobErrorMsg: tt.msg,
|
||||||
|
})
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if problem.Code != 0 {
|
||||||
|
t.Fatalf("code = %d, want 0", problem.Code)
|
||||||
|
}
|
||||||
|
if problem.Retryable {
|
||||||
|
t.Fatal("expected non-concurrency failure to remain non-retryable")
|
||||||
|
}
|
||||||
|
if problem.Hint != "" {
|
||||||
|
t.Fatalf("hint = %q, want empty", problem.Hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
|
|||||||
|
|
||||||
var downloaded, skipped, failed, deletedLocal int
|
var downloaded, skipped, failed, deletedLocal int
|
||||||
downloadFailed := 0
|
downloadFailed := 0
|
||||||
|
aborted := false
|
||||||
items := make([]drivePullItem, 0)
|
items := make([]drivePullItem, 0)
|
||||||
|
|
||||||
// Deterministic iteration order for output stability.
|
// Deterministic iteration order for output stability.
|
||||||
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
|
|||||||
sort.Strings(downloadablePaths)
|
sort.Strings(downloadablePaths)
|
||||||
|
|
||||||
for _, rel := range downloadablePaths {
|
for _, rel := range downloadablePaths {
|
||||||
if drivePullHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
targetFile := remoteFiles[rel]
|
targetFile := remoteFiles[rel]
|
||||||
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
downloadFailed++
|
downloadFailed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"deleted_local": deletedLocal,
|
"deleted_local": deletedLocal,
|
||||||
"aborted": drivePullHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
|||||||
return item, decision.Terminal
|
return item, decision.Terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
|
||||||
for _, item := range items {
|
|
||||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// drivePullDownload streams one Drive file into the local mirror target and
|
// drivePullDownload streams one Drive file into the local mirror target and
|
||||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type drivePushItem struct {
|
|||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
ErrorClass string `json:"error_class,omitempty"`
|
ErrorClass string `json:"error_class,omitempty"`
|
||||||
Code int `json:"code,omitempty"`
|
Code int `json:"code,omitempty"`
|
||||||
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
|
|||||||
Subtype string
|
Subtype string
|
||||||
Retryable bool
|
Retryable bool
|
||||||
Terminal bool
|
Terminal bool
|
||||||
|
Hint string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||||
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
|
|||||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||||
// outcome the review flagged.
|
// outcome the review flagged.
|
||||||
uploadFailed := false
|
uploadFailed := false
|
||||||
|
aborted := false
|
||||||
|
|
||||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||||
// listing (so we don't recreate folders that already exist) and
|
// listing (so we don't recreate folders that already exist) and
|
||||||
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
|
|||||||
|
|
||||||
for _, rel := range localPaths {
|
for _, rel := range localPaths {
|
||||||
localFile := localFiles[rel]
|
localFile := localFiles[rel]
|
||||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
if uploadFailed && aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
|
|||||||
failed++
|
failed++
|
||||||
uploadFailed = true
|
uploadFailed = true
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||||
|
if drivePushIsAlreadyDeleted(err) {
|
||||||
|
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||||
abortDelete = true
|
abortDelete = true
|
||||||
break
|
break
|
||||||
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
|
|||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"deleted_remote": deletedRemote,
|
"deleted_remote": deletedRemote,
|
||||||
"aborted": drivePushHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
|||||||
Action: action,
|
Action: action,
|
||||||
SizeBytes: sizeBytes,
|
SizeBytes: sizeBytes,
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
|
Hint: decision.Hint,
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
ErrorClass: decision.Class,
|
ErrorClass: decision.Class,
|
||||||
Code: decision.Code,
|
Code: decision.Code,
|
||||||
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|||||||
decision.Class = "file_size_limit"
|
decision.Class = "file_size_limit"
|
||||||
case problem.Code == 1062009:
|
case problem.Code == 1062009:
|
||||||
decision.Class = "upload_size_mismatch"
|
decision.Class = "upload_size_mismatch"
|
||||||
|
case problem.Code == 1061044:
|
||||||
|
decision.Class = "parent_node_missing"
|
||||||
|
decision.Terminal = true
|
||||||
|
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
||||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||||
decision.Class = "remote_not_found"
|
decision.Class = "remote_not_found"
|
||||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||||
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
|||||||
return decision
|
return decision
|
||||||
}
|
}
|
||||||
|
|
||||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
func drivePushIsAlreadyDeleted(err error) bool {
|
||||||
for _, item := range items {
|
problem, ok := errs.ProblemOf(err)
|
||||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
return ok && problem.Code == 1061007
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
|
||||||
switch errorClass {
|
|
||||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||||
|
|||||||
@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
withDriveWorkingDir(t, tmpDir)
|
||||||
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "folder_token=folder_root",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"files": []interface{}{
|
||||||
|
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||||
|
},
|
||||||
|
"has_more": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE",
|
||||||
|
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 1061007,
|
||||||
|
"msg": "file has been delete.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRunDrive(t, DrivePush, []string{
|
||||||
|
"+push",
|
||||||
|
"--local-dir", "local",
|
||||||
|
"--folder-token", "folder_root",
|
||||||
|
"--delete-remote",
|
||||||
|
"--yes",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||||
|
if got := summary["failed"]; got != float64(0) {
|
||||||
|
t.Fatalf("summary.failed = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := summary["deleted_remote"]; got != float64(0) {
|
||||||
|
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||||
|
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
withDriveWorkingDir(t, tmpDir)
|
||||||
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile a: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "folder_token=folder_root",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/drive/v1/files/upload_all",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 1061044,
|
||||||
|
"msg": "parent node not exist.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRunDrive(t, DrivePush, []string{
|
||||||
|
"+push",
|
||||||
|
"--local-dir", "local",
|
||||||
|
"--folder-token", "folder_root",
|
||||||
|
"--as", "bot",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||||
|
}
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||||
|
if got := summary["failed"]; got != float64(1) {
|
||||||
|
t.Fatalf("summary.failed = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got := summary["aborted"]; got != true {
|
||||||
|
t.Fatalf("summary.aborted = %v, want true", got)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||||
|
}
|
||||||
|
item := items[0]
|
||||||
|
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||||
|
t.Fatalf("unexpected failed item: %#v", item)
|
||||||
|
}
|
||||||
|
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||||
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||||
|
}
|
||||||
|
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||||
|
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item["rel_path"] == "b.txt" {
|
||||||
|
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||||
|
|
||||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// --- Phase 2: Execute sync operations ---
|
// --- Phase 2: Execute sync operations ---
|
||||||
var pulled, pushed, skipped, failed int
|
var pulled, pushed, skipped, failed int
|
||||||
|
aborted := false
|
||||||
items := make([]driveSyncItem, 0)
|
items := make([]driveSyncItem, 0)
|
||||||
|
|
||||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||||
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
|
|||||||
// Mirror local directory structure first (same as +push), so
|
// Mirror local directory structure first (same as +push), so
|
||||||
// empty local directories are not silently dropped.
|
// empty local directories are not silently dropped.
|
||||||
for _, relDir := range localDirs {
|
for _, relDir := range localDirs {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||||
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2a. Pull new_remote files.
|
// 2a. Pull new_remote files.
|
||||||
for _, entry := range newRemote {
|
for _, entry := range newRemote {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||||
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2b. Push new_local files.
|
// 2b. Push new_local files.
|
||||||
for _, entry := range newLocal {
|
for _, entry := range newLocal {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||||
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
|
|||||||
parentRel := drivePushParentRel(entry.RelPath)
|
parentRel := drivePushParentRel(entry.RelPath)
|
||||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||||
if ensureErr != nil {
|
if ensureErr != nil {
|
||||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||||
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
|
|||||||
|
|
||||||
// 2c. Resolve modified files by --on-conflict strategy.
|
// 2c. Resolve modified files by --on-conflict strategy.
|
||||||
for _, entry := range modified {
|
for _, entry := range modified {
|
||||||
if driveSyncHasTerminalFailure(items) {
|
if aborted {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
remoteFile := remoteFiles[entry.RelPath]
|
remoteFile := remoteFiles[entry.RelPath]
|
||||||
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
|
|||||||
}
|
}
|
||||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||||
if parentErr != nil {
|
if parentErr != nil {
|
||||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
|
if terminal {
|
||||||
|
aborted = true
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||||
|
break
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||||
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
|
|||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
failed++
|
failed++
|
||||||
if terminal {
|
if terminal {
|
||||||
|
aborted = true
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
|
|||||||
"pushed": pushed,
|
"pushed": pushed,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"aborted": driveSyncHasTerminalFailure(items),
|
"aborted": aborted,
|
||||||
},
|
},
|
||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
|||||||
return item, decision.Terminal
|
return item, decision.Terminal
|
||||||
}
|
}
|
||||||
|
|
||||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
|
||||||
for _, item := range items {
|
|
||||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||||
// for a single file. Returns the strategy string, or empty string if the
|
// for a single file. Returns the strategy string, or empty string if the
|
||||||
// user chose to skip.
|
// user chose to skip.
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
|
|||||||
want := []string{
|
want := []string{
|
||||||
"+chat-create",
|
"+chat-create",
|
||||||
"+chat-list",
|
"+chat-list",
|
||||||
|
"+chat-members-list",
|
||||||
"+chat-messages-list",
|
"+chat-messages-list",
|
||||||
"+chat-search",
|
"+chat-search",
|
||||||
"+chat-update",
|
"+chat-update",
|
||||||
|
|||||||
420
shortcuts/im/im_chat_members_list.go
Normal file
420
shortcuts/im/im_chat_members_list.go
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package im
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
|
||||||
|
chatMembersListDefaultPageSize = 20
|
||||||
|
chatMembersListMaxPageSize = 100
|
||||||
|
// chatMembersListDefaultPageDelay throttles --page-all the same way the
|
||||||
|
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
|
||||||
|
// server-side member cap, where a large group drains many pages back to
|
||||||
|
// back and could otherwise trip rate limits.
|
||||||
|
chatMembersListDefaultPageDelay = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
|
||||||
|
// returning users and bots in separate buckets (users[]/bots[]). It owns its
|
||||||
|
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
|
||||||
|
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
|
||||||
|
// the response is multi-bucket — the generic --page-all merger is built for
|
||||||
|
// single-array responses and would drop the bots[] bucket and the final-page
|
||||||
|
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
|
||||||
|
var ImChatMembersList = common.Shortcut{
|
||||||
|
Service: "im",
|
||||||
|
Command: "+chat-members-list",
|
||||||
|
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
|
||||||
|
Risk: "read",
|
||||||
|
// Declare the narrowest scope the API accepts so tokens carrying only
|
||||||
|
// im:chat.members:read are honored (same rationale as +chat-list).
|
||||||
|
Scopes: []string{"im:chat.members:read"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
|
||||||
|
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
|
||||||
|
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||||
|
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
|
||||||
|
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
|
||||||
|
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
|
||||||
|
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
|
||||||
|
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
|
||||||
|
},
|
||||||
|
Tips: []string{
|
||||||
|
"Default fetches a single page; pass --page-all to walk every page.",
|
||||||
|
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
|
||||||
|
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||||
|
if chatID == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(chatID, "oc_") {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
|
||||||
|
}
|
||||||
|
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
|
||||||
|
}
|
||||||
|
if n := runtime.Int("page-limit"); n < 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
|
||||||
|
}
|
||||||
|
if n := runtime.Int("page-delay"); n < 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
|
||||||
|
}
|
||||||
|
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||||
|
dry := common.NewDryRunAPI()
|
||||||
|
if chatMembersShouldAutoPaginate(runtime) {
|
||||||
|
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
|
||||||
|
}
|
||||||
|
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||||
|
return dry.
|
||||||
|
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
|
||||||
|
Params(params)
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
warnIfConflictingPagingFlags(runtime)
|
||||||
|
|
||||||
|
chatID := strings.TrimSpace(runtime.Str("chat-id"))
|
||||||
|
res, err := fetchChatMembers(ctx, runtime, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The truncation signal is the whole reason this is a dedicated shortcut:
|
||||||
|
// surface it loudly so an agent never mistakes a capped list for a
|
||||||
|
// complete one.
|
||||||
|
if len(res.truncations) > 0 {
|
||||||
|
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
|
||||||
|
|
||||||
|
outData := map[string]interface{}{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"users": res.users,
|
||||||
|
"bots": res.bots,
|
||||||
|
"truncations": res.truncations,
|
||||||
|
"has_more": res.hasMore,
|
||||||
|
"page_token": res.pageToken,
|
||||||
|
}
|
||||||
|
if res.userTotal != nil {
|
||||||
|
outData["user_total"] = res.userTotal
|
||||||
|
}
|
||||||
|
if res.botTotal != nil {
|
||||||
|
outData["bot_total"] = res.botTotal
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
|
||||||
|
renderChatMembersPretty(w, chatID, res)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatMembersResult is the aggregated view across one or more pages.
|
||||||
|
type chatMembersResult struct {
|
||||||
|
users []interface{}
|
||||||
|
bots []interface{}
|
||||||
|
truncations []interface{}
|
||||||
|
userTotal interface{}
|
||||||
|
botTotal interface{}
|
||||||
|
hasMore bool
|
||||||
|
pageToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveChatMembersPageSize resolves the page_size to request. When draining
|
||||||
|
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
|
||||||
|
// uses the maximum so a full walk takes the fewest round-trips. An explicit
|
||||||
|
// --page-size is always honored; without --page-all the smaller default is kept
|
||||||
|
// as a sensible single-page preview size.
|
||||||
|
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
|
||||||
|
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
|
||||||
|
return chatMembersListMaxPageSize
|
||||||
|
}
|
||||||
|
if n := runtime.Int("page-size"); n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return chatMembersListDefaultPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
|
||||||
|
// every page. An explicit --page-token disables the auto loop because the
|
||||||
|
// caller supplied a specific cursor (single-page fetch).
|
||||||
|
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
|
||||||
|
if strings.TrimSpace(runtime.Str("page-token")) != "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return runtime.Bool("page-all")
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildChatMembersParams builds the query params for one page request. The
|
||||||
|
// startToken (when non-empty) seeds the page_token; the loop overrides it per
|
||||||
|
// page. Returns the params and the normalized member-types CSV (already
|
||||||
|
// validated by Validate, so the error is only a defensive guard).
|
||||||
|
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
|
||||||
|
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"member_id_type": runtime.Str("member-id-type"),
|
||||||
|
"page_size": effectiveChatMembersPageSize(runtime),
|
||||||
|
}
|
||||||
|
if memberTypes != "" {
|
||||||
|
params["member_types"] = memberTypes
|
||||||
|
}
|
||||||
|
if startToken != "" {
|
||||||
|
params["page_token"] = startToken
|
||||||
|
}
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchChatMembers walks the list_members endpoint, honoring the four
|
||||||
|
// pagination flags the same way the generic --page-all path does. It merges
|
||||||
|
// each page into the aggregate as it arrives (rather than buffering every raw
|
||||||
|
// page), so peak memory is just the aggregated members plus the single most
|
||||||
|
// recent page — important for large groups under --page-limit 0.
|
||||||
|
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
|
||||||
|
auto := chatMembersShouldAutoPaginate(runtime)
|
||||||
|
pageLimit := runtime.Int("page-limit")
|
||||||
|
pageDelay := runtime.Int("page-delay")
|
||||||
|
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
|
||||||
|
|
||||||
|
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := newChatMembersResult()
|
||||||
|
var lastData map[string]interface{}
|
||||||
|
pageToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||||
|
for page := 0; ; page++ {
|
||||||
|
if pageToken != "" {
|
||||||
|
params["page_token"] = pageToken
|
||||||
|
}
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
|
||||||
|
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addMemberBuckets(res, data)
|
||||||
|
lastData = data
|
||||||
|
|
||||||
|
hasMore, nextToken := common.PaginationMeta(data)
|
||||||
|
if !auto {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !hasMore || nextToken == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if nextToken == pageToken {
|
||||||
|
// Guard against a buggy server echoing the same cursor with
|
||||||
|
// has_more=true: without --page-limit we would loop forever.
|
||||||
|
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if pageLimit > 0 && page+1 >= pageLimit {
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pageToken = nextToken
|
||||||
|
// Throttle between pages (only reached when another page follows), so
|
||||||
|
// draining a large untruncated list doesn't hammer the API.
|
||||||
|
if pageDelay > 0 {
|
||||||
|
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastData != nil {
|
||||||
|
applyLastPageSignals(res, lastData)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
|
||||||
|
// JSON output always carries arrays (never null).
|
||||||
|
func newChatMembersResult() *chatMembersResult {
|
||||||
|
return &chatMembersResult{
|
||||||
|
users: []interface{}{},
|
||||||
|
bots: []interface{}{},
|
||||||
|
truncations: []interface{}{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
|
||||||
|
// Concatenating every bucket is what avoids dropping bots[] — the bug the
|
||||||
|
// generic single-array --page-all merger would hit on this multi-bucket shape.
|
||||||
|
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
|
||||||
|
if u, ok := data["users"].([]interface{}); ok {
|
||||||
|
res.users = append(res.users, u...)
|
||||||
|
}
|
||||||
|
if b, ok := data["bots"].([]interface{}); ok {
|
||||||
|
res.bots = append(res.bots, b...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLastPageSignals copies the per-request signals from the FINAL page:
|
||||||
|
// has_more / page_token / truncations / totals. These must come from the last
|
||||||
|
// page, not page 1: truncations[] is emitted only on the final page (empty
|
||||||
|
// earlier), so reading it sooner would hide a server-side cap; user_total /
|
||||||
|
// bot_total are server-wide counts, and taking the final page's value keeps a
|
||||||
|
// single, consistent source rather than a possibly-stale earlier count.
|
||||||
|
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
|
||||||
|
res.hasMore, res.pageToken = common.PaginationMeta(data)
|
||||||
|
if t, ok := data["truncations"].([]interface{}); ok {
|
||||||
|
res.truncations = t
|
||||||
|
}
|
||||||
|
res.userTotal = data["user_total"]
|
||||||
|
res.botTotal = data["bot_total"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
|
||||||
|
// the same logic fetchChatMembers applies incrementally, kept as a pure
|
||||||
|
// function so the multi-bucket merge + last-page-signal semantics are unit
|
||||||
|
// tested in one place.
|
||||||
|
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
|
||||||
|
res := newChatMembersResult()
|
||||||
|
if len(pages) == 0 {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
for _, data := range pages {
|
||||||
|
addMemberBuckets(res, data)
|
||||||
|
}
|
||||||
|
applyLastPageSignals(res, pages[len(pages)-1])
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
|
||||||
|
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
|
||||||
|
// the API's default of all types). Any element outside {user, bot} is rejected.
|
||||||
|
func normalizeMemberTypes(raw []string) (string, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(raw))
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, p := range raw {
|
||||||
|
p = strings.TrimSpace(strings.ToLower(p))
|
||||||
|
if p != "user" && p != "bot" {
|
||||||
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
|
||||||
|
}
|
||||||
|
if _, dup := seen[p]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ","), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
|
||||||
|
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
|
||||||
|
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
|
||||||
|
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
|
||||||
|
fmt.Fprintln(runtime.IO().ErrOut,
|
||||||
|
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeChatMembersTruncationWarning emits a stderr warning for every
|
||||||
|
// server-side bucket cap reported in truncations[]. It uses the repo's plain
|
||||||
|
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
|
||||||
|
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
|
||||||
|
// pipes regardless of terminal encoding.
|
||||||
|
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
|
||||||
|
for _, t := range truncations {
|
||||||
|
tm, ok := t.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
memberType := valueOrAll(tm["member_type"])
|
||||||
|
limit := tm["limit"]
|
||||||
|
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueOrAll(v interface{}) string {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "member"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
|
||||||
|
fmt.Fprintf(w, "Chat: %s\n", chatID)
|
||||||
|
// Show the server-wide total next to the fetched count: when truncated or
|
||||||
|
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
|
||||||
|
// what tells the reader how incomplete the list is.
|
||||||
|
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
|
||||||
|
for i, u := range res.users {
|
||||||
|
m, _ := u.(map[string]interface{})
|
||||||
|
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
|
||||||
|
for i, b := range res.bots {
|
||||||
|
m, _ := b.(map[string]interface{})
|
||||||
|
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
|
||||||
|
}
|
||||||
|
if len(res.truncations) > 0 {
|
||||||
|
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
|
||||||
|
}
|
||||||
|
if res.hasMore {
|
||||||
|
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
|
||||||
|
if res.pageToken != "" {
|
||||||
|
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueOrDash(v interface{}) string {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// totalSuffix renders " of <total>" when the server-reported total exceeds the
|
||||||
|
// number actually fetched (so a truncated/partial bucket is obvious), and ""
|
||||||
|
// when the total is absent or already matches the fetched count.
|
||||||
|
func totalSuffix(total interface{}, fetched int) string {
|
||||||
|
n, ok := toInt(total)
|
||||||
|
if !ok || n <= fetched {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" of %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
|
||||||
|
func toInt(v interface{}) (int, bool) {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(n), true
|
||||||
|
case int:
|
||||||
|
return n, true
|
||||||
|
case int64:
|
||||||
|
return int(n), true
|
||||||
|
case json.Number:
|
||||||
|
if i, err := n.Int64(); err == nil {
|
||||||
|
return int(i), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
325
shortcuts/im/im_chat_members_list_test.go
Normal file
325
shortcuts/im/im_chat_members_list_test.go
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package im
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// page builds one list_members page payload shaped like the data object the
|
||||||
|
// server returns (users[]/bots[]/truncations[] plus paging + totals).
|
||||||
|
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"users": users,
|
||||||
|
"bots": bots,
|
||||||
|
"truncations": truncations,
|
||||||
|
"has_more": hasMore,
|
||||||
|
"page_token": pageToken,
|
||||||
|
"user_total": 324,
|
||||||
|
"bot_total": 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func us(ids ...string) []interface{} {
|
||||||
|
out := make([]interface{}, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
out = append(out, map[string]interface{}{"member_id": id})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
|
||||||
|
// (users AND bots) must be concatenated across pages, not just one of them.
|
||||||
|
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
|
||||||
|
pages := []map[string]interface{}{
|
||||||
|
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||||
|
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
res := mergeChatMemberPages(pages)
|
||||||
|
|
||||||
|
if len(res.users) != 3 {
|
||||||
|
t.Errorf("users: want 3 merged, got %d", len(res.users))
|
||||||
|
}
|
||||||
|
if len(res.bots) != 3 {
|
||||||
|
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
|
||||||
|
// is emitted only on the final page, so the merged view must take it from the
|
||||||
|
// last page rather than inherit page 1's empty slice.
|
||||||
|
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
|
||||||
|
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
|
||||||
|
pages := []map[string]interface{}{
|
||||||
|
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
|
||||||
|
cmlPage(us("u2"), nil, limit, false, ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
res := mergeChatMemberPages(pages)
|
||||||
|
|
||||||
|
if len(res.truncations) != 1 {
|
||||||
|
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
|
||||||
|
// signals come from the final page (so a --page-limit cutoff is visible).
|
||||||
|
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
|
||||||
|
pages := []map[string]interface{}{
|
||||||
|
cmlPage(us("u1"), nil, nil, true, "p2"),
|
||||||
|
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
|
||||||
|
}
|
||||||
|
|
||||||
|
res := mergeChatMemberPages(pages)
|
||||||
|
|
||||||
|
if !res.hasMore {
|
||||||
|
t.Error("has_more: want true from last page")
|
||||||
|
}
|
||||||
|
if res.pageToken != "p3" {
|
||||||
|
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
|
||||||
|
// are taken from the final page (not an earlier, possibly-different value).
|
||||||
|
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
|
||||||
|
pages := []map[string]interface{}{
|
||||||
|
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
|
||||||
|
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
|
||||||
|
}
|
||||||
|
res := mergeChatMemberPages(pages)
|
||||||
|
if n, _ := toInt(res.userTotal); n != 324 {
|
||||||
|
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
|
||||||
|
}
|
||||||
|
if n, _ := toInt(res.botTotal); n != 2 {
|
||||||
|
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
|
||||||
|
func TestChatMembersValidate(t *testing.T) {
|
||||||
|
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||||
|
})
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
chatID string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid oc_", "oc_abc", false},
|
||||||
|
{"empty", "", true},
|
||||||
|
{"missing oc_ prefix", "abc123", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
|
||||||
|
err := ImChatMembersList.Validate(context.Background(), rt)
|
||||||
|
if c.wantErr {
|
||||||
|
assertValidationError(t, c.name, err, "--chat-id")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: unexpected error %v", c.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertValidationError checks err satisfies the repo's typed-error contract for
|
||||||
|
// a validation failure: a *errs.ValidationError carrying the expected Param, and
|
||||||
|
// problem metadata of category validation / subtype invalid_argument.
|
||||||
|
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
|
||||||
|
t.Helper()
|
||||||
|
var ve *errs.ValidationError
|
||||||
|
if !errors.As(err, &ve) {
|
||||||
|
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ve.Param != wantParam {
|
||||||
|
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMemberTypes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in []string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{nil, "", false},
|
||||||
|
{[]string{"user", "bot"}, "user,bot", false},
|
||||||
|
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
|
||||||
|
{[]string{"admin"}, "", true},
|
||||||
|
{[]string{""}, "", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := normalizeMemberTypes(c.in)
|
||||||
|
if c.wantErr {
|
||||||
|
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
|
||||||
|
// drain with no explicit size → max; explicit size → honored; single page → default.
|
||||||
|
func TestEffectiveChatMembersPageSize(t *testing.T) {
|
||||||
|
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
|
||||||
|
})
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
b map[string]bool
|
||||||
|
ints map[string]int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
|
||||||
|
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
|
||||||
|
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
|
||||||
|
if got := effectiveChatMembersPageSize(rt); got != c.want {
|
||||||
|
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newChatMembersTestRuntime registers the shortcut's flags and returns a
|
||||||
|
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
|
||||||
|
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
|
||||||
|
t.Helper()
|
||||||
|
runtime := newUserShortcutRuntime(t, rt)
|
||||||
|
cmd := &cobra.Command{Use: "test"}
|
||||||
|
cmd.Flags().String("chat-id", "", "")
|
||||||
|
cmd.Flags().String("member-id-type", "open_id", "")
|
||||||
|
cmd.Flags().StringSlice("member-types", nil, "")
|
||||||
|
cmd.Flags().String("page-token", "", "")
|
||||||
|
cmd.Flags().Bool("page-all", false, "")
|
||||||
|
cmd.Flags().Int("page-size", 20, "")
|
||||||
|
cmd.Flags().Int("page-limit", 10, "")
|
||||||
|
cmd.Flags().Int("page-delay", 200, "")
|
||||||
|
if err := cmd.ParseFlags(nil); err != nil {
|
||||||
|
t.Fatalf("ParseFlags: %v", err)
|
||||||
|
}
|
||||||
|
for k, v := range str {
|
||||||
|
if err := cmd.Flags().Set(k, v); err != nil {
|
||||||
|
t.Fatalf("set %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range b {
|
||||||
|
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
|
||||||
|
t.Fatalf("set %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range ints {
|
||||||
|
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
|
||||||
|
t.Fatalf("set %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.Cmd = cmd
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
|
||||||
|
// fetch loop over mocked pages: users/bots merge across pages and the final
|
||||||
|
// page's truncations[] survives.
|
||||||
|
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
|
||||||
|
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
|
||||||
|
}
|
||||||
|
calls++
|
||||||
|
token := req.URL.Query().Get("page_token")
|
||||||
|
if token == "" {
|
||||||
|
return shortcutJSONResponse(200, map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
return shortcutJSONResponse(200, map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
|
||||||
|
}), nil
|
||||||
|
})
|
||||||
|
runtime := newChatMembersTestRuntime(t, rt,
|
||||||
|
map[string]string{"chat-id": "oc_test"},
|
||||||
|
map[string]bool{"page-all": true},
|
||||||
|
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
|
||||||
|
|
||||||
|
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchChatMembers: %v", err)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Errorf("want 2 page calls, got %d", calls)
|
||||||
|
}
|
||||||
|
if len(res.users) != 3 {
|
||||||
|
t.Errorf("users: want 3, got %d", len(res.users))
|
||||||
|
}
|
||||||
|
if len(res.bots) != 2 {
|
||||||
|
t.Errorf("bots: want 2, got %d", len(res.bots))
|
||||||
|
}
|
||||||
|
if len(res.truncations) != 1 {
|
||||||
|
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
|
||||||
|
}
|
||||||
|
if res.hasMore {
|
||||||
|
t.Error("has_more: want false after draining all pages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
|
||||||
|
// leaves has_more=true so the caller knows the result is incomplete.
|
||||||
|
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
|
||||||
|
seq := 0
|
||||||
|
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
// Every page reports more pages available, with an advancing token so the
|
||||||
|
// loop is stopped by --page-limit, not the non-advancing-token guard.
|
||||||
|
seq++
|
||||||
|
return shortcutJSONResponse(200, map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
|
||||||
|
}), nil
|
||||||
|
})
|
||||||
|
runtime := newChatMembersTestRuntime(t, rt,
|
||||||
|
map[string]string{"chat-id": "oc_test"},
|
||||||
|
map[string]bool{"page-all": true},
|
||||||
|
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
|
||||||
|
|
||||||
|
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchChatMembers: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.users) != 3 {
|
||||||
|
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
|
||||||
|
}
|
||||||
|
if !res.hasMore {
|
||||||
|
t.Error("has_more: want true (loop cut short by page-limit)")
|
||||||
|
}
|
||||||
|
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
|
||||||
|
if !strings.Contains(errOut.String(), "reached page limit (3)") {
|
||||||
|
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
|
|||||||
return []common.Shortcut{
|
return []common.Shortcut{
|
||||||
ImChatCreate,
|
ImChatCreate,
|
||||||
ImChatList,
|
ImChatList,
|
||||||
|
ImChatMembersList,
|
||||||
ImChatMessageList,
|
ImChatMessageList,
|
||||||
ImChatSearch,
|
ImChatSearch,
|
||||||
ImChatUpdate,
|
ImChatUpdate,
|
||||||
|
|||||||
@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
|
|||||||
// original message as read after a reply/reply-all/forward operation.
|
// original message as read after a reply/reply-all/forward operation.
|
||||||
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut,
|
fmt.Fprintf(runtime.IO().ErrOut,
|
||||||
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+
|
"tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
|
||||||
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n",
|
shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
|
||||||
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
// hintReadReceiptRequest prints a stderr tip when a message that the caller
|
||||||
|
|||||||
@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
|
|||||||
// TestHintMarkAsRead verifies hint mark as read.
|
// TestHintMarkAsRead verifies hint mark as read.
|
||||||
func TestHintMarkAsRead(t *testing.T) {
|
func TestHintMarkAsRead(t *testing.T) {
|
||||||
rt, _, stderr := newOutputRuntime(t)
|
rt, _, stderr := newOutputRuntime(t)
|
||||||
// Inject ANSI escape + message ID to verify sanitization
|
hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
|
||||||
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
|
|
||||||
out := stderr.String()
|
out := stderr.String()
|
||||||
if strings.Contains(out, "\x1b[") {
|
if strings.Contains(out, "\x1b[") {
|
||||||
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "msg-123") {
|
if strings.Contains(out, "\nnext") {
|
||||||
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
|
t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
|
||||||
|
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
|
||||||
|
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
482
shortcuts/mail/mail_message_manage_test.go
Normal file
482
shortcuts/mail/mail_message_manage_test.go
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/internal/auth"
|
||||||
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func messageManageID(suffix string) string {
|
||||||
|
return "msg_abcdefghijklmnop_" + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
|
||||||
|
stub := &httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/user_mailboxes/me/messages/" + endpoint,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
reg.Register(stub)
|
||||||
|
return stub
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
success, ok := data["success_message_ids"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
|
||||||
|
}
|
||||||
|
failed, ok := data["failed_message_ids"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
|
||||||
|
}
|
||||||
|
return success, failed
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error for %s, got nil", param)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed Problem for %s, got %T", param, err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||||
|
}
|
||||||
|
if validationErr.Param != param {
|
||||||
|
t.Fatalf("param = %q, want %q", validationErr.Param, param)
|
||||||
|
}
|
||||||
|
return validationErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected failed precondition error, got nil")
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed Problem, got %T", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
||||||
|
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||||
|
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
|
||||||
|
}
|
||||||
|
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
|
||||||
|
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := [][]string{
|
||||||
|
{""},
|
||||||
|
{" id_with_leading_space_12345"},
|
||||||
|
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
|
||||||
|
{"1234567890123456"},
|
||||||
|
{"short"},
|
||||||
|
{"msg_abcdefghijklmnop!"},
|
||||||
|
{"msg_abcdefghijklmnop\t"},
|
||||||
|
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
|
||||||
|
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, err := normalizeMessageManageIDs(tc)
|
||||||
|
requireMessageManageValidationParam(t, err, "--message-ids")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_Metadata(t *testing.T) {
|
||||||
|
if MailMessageModify.Command != "+message-modify" {
|
||||||
|
t.Fatalf("Command = %q", MailMessageModify.Command)
|
||||||
|
}
|
||||||
|
if MailMessageModify.Risk != "write" {
|
||||||
|
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
|
||||||
|
}
|
||||||
|
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
|
||||||
|
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
|
||||||
|
}
|
||||||
|
requiredScopes := map[string]bool{
|
||||||
|
"mail:user_mailbox.message:modify": true,
|
||||||
|
}
|
||||||
|
for _, scope := range MailMessageModify.Scopes {
|
||||||
|
delete(requiredScopes, scope)
|
||||||
|
}
|
||||||
|
if len(requiredScopes) != 0 {
|
||||||
|
t.Errorf("Scopes missing %v", requiredScopes)
|
||||||
|
}
|
||||||
|
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
|
||||||
|
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
|
||||||
|
}
|
||||||
|
flags := map[string]common.Flag{}
|
||||||
|
for _, fl := range MailMessageModify.Flags {
|
||||||
|
flags[fl.Name] = fl
|
||||||
|
}
|
||||||
|
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
|
||||||
|
if _, ok := flags[name]; !ok {
|
||||||
|
t.Fatalf("missing --%s flag", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
|
||||||
|
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageTrash_Metadata(t *testing.T) {
|
||||||
|
if MailMessageTrash.Command != "+message-trash" {
|
||||||
|
t.Fatalf("Command = %q", MailMessageTrash.Command)
|
||||||
|
}
|
||||||
|
if MailMessageTrash.Risk != "high-risk-write" {
|
||||||
|
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
|
||||||
|
}
|
||||||
|
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
|
||||||
|
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
|
||||||
|
}
|
||||||
|
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
|
||||||
|
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
token := auth.GetStoredToken("test-app", "ou_testuser")
|
||||||
|
if token == nil {
|
||||||
|
t.Fatal("expected test token")
|
||||||
|
}
|
||||||
|
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
|
||||||
|
if err := auth.SetStoredToken(token); err != nil {
|
||||||
|
t.Fatalf("SetStoredToken() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := messageManageID("1")
|
||||||
|
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--remove-label-ids", "UNREAD",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
removeLabels := body["remove_label_ids"].([]interface{})
|
||||||
|
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
|
||||||
|
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--remove-label-ids", "read_receipt_request",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
removeLabels := body["remove_label_ids"].([]interface{})
|
||||||
|
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
|
||||||
|
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
|
||||||
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
|
||||||
|
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-label-ids", "unread,customA",
|
||||||
|
"--remove-label-ids", "FLAGGED",
|
||||||
|
"--add-folder", "folderA",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
if got := body["add_folder"]; got != "folderA" {
|
||||||
|
t.Errorf("add_folder = %v, want folderA", got)
|
||||||
|
}
|
||||||
|
addLabels := body["add_label_ids"].([]interface{})
|
||||||
|
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
|
||||||
|
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
|
||||||
|
}
|
||||||
|
removeLabels := body["remove_label_ids"].([]interface{})
|
||||||
|
if removeLabels[0] != "FLAGGED" {
|
||||||
|
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 1 || success[0] != id || len(failed) != 0 {
|
||||||
|
t.Errorf("summary success=%v failed=%v", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-label-ids", "unread",
|
||||||
|
"--remove-label-ids", "UNREAD",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageValidationParam(t, err, "--add-label-ids")
|
||||||
|
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
|
||||||
|
t.Fatalf("error = %v, want label intersection validation", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-folder", "trash",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageValidationParam(t, err, "--add-folder")
|
||||||
|
if !strings.Contains(err.Error(), "use +message-trash") {
|
||||||
|
t.Fatalf("error = %v, want TRASH validation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id1 + "," + id2 + "," + id1,
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
|
||||||
|
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
ids := make([]string, 41)
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
|
||||||
|
}
|
||||||
|
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||||
|
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", strings.Join(ids, ","),
|
||||||
|
"--add-folder", "archive",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
for idx, stub := range []*httpmock.Stub{first, second, third} {
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
|
||||||
|
}
|
||||||
|
messageIDs := body["message_ids"].([]interface{})
|
||||||
|
want := []int{20, 20, 1}[idx]
|
||||||
|
if len(messageIDs) != want {
|
||||||
|
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
|
||||||
|
}
|
||||||
|
if body["add_folder"] != "ARCHIVED" {
|
||||||
|
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 21 || len(failed) != 20 {
|
||||||
|
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--add-folder", "archive",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageFailedPrecondition(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageModify, []string{
|
||||||
|
"+message-modify",
|
||||||
|
"--message-ids", id1 + "," + id2,
|
||||||
|
"--add-label-ids", "customA",
|
||||||
|
"--add-folder", "folderA",
|
||||||
|
"--dry-run",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run failed: %v", err)
|
||||||
|
}
|
||||||
|
out := stdout.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`/user_mailboxes/me/messages/batch_modify`,
|
||||||
|
`validation_api_plan`,
|
||||||
|
`/user_mailboxes/me/labels/customA`,
|
||||||
|
`/user_mailboxes/me/folders/folderA`,
|
||||||
|
`will_validate`,
|
||||||
|
`batch_size`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Fatalf("dry-run output missing %q; got %s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||||
|
"+message-trash",
|
||||||
|
"--message-ids", id1 + "," + id2,
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected confirmation error, got nil")
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||||
|
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
|
||||||
|
err = runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||||
|
"+message-trash",
|
||||||
|
"--message-ids", id1 + "," + id2,
|
||||||
|
"--yes",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err with --yes: %v", err)
|
||||||
|
}
|
||||||
|
var body map[string]interface{}
|
||||||
|
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal captured body: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(body["message_ids"].([]interface{})); got != 2 {
|
||||||
|
t.Fatalf("message_ids len = %d, want 2", got)
|
||||||
|
}
|
||||||
|
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
|
||||||
|
if len(success) != 2 || len(failed) != 0 {
|
||||||
|
t.Fatalf("summary success=%v failed=%v", success, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
|
||||||
|
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||||
|
id := messageManageID("1")
|
||||||
|
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
|
||||||
|
|
||||||
|
err := runMountedMailShortcut(t, MailMessageTrash, []string{
|
||||||
|
"+message-trash",
|
||||||
|
"--message-ids", id,
|
||||||
|
"--yes",
|
||||||
|
}, f, stdout)
|
||||||
|
requireMessageManageFailedPrecondition(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
|
||||||
|
id1 := messageManageID("1")
|
||||||
|
id2 := messageManageID("2")
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
shortcut common.Shortcut
|
||||||
|
args []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "trash newline in repeated flag",
|
||||||
|
shortcut: MailMessageTrash,
|
||||||
|
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trash tab in csv flag",
|
||||||
|
shortcut: MailMessageTrash,
|
||||||
|
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modify space in repeated flag",
|
||||||
|
shortcut: MailMessageModify,
|
||||||
|
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "modify space in csv flag",
|
||||||
|
shortcut: MailMessageModify,
|
||||||
|
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f, stdout, _, _ := mailShortcutTestFactory(t)
|
||||||
|
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||||
|
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
|
||||||
|
t.Fatalf("error = %v, want whitespace/control validation", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
141
shortcuts/mail/mail_message_modify.go
Normal file
141
shortcuts/mail/mail_message_modify.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type messageModifyInput struct {
|
||||||
|
MessageIDs []string
|
||||||
|
AddLabelIDs []string
|
||||||
|
RemoveLabelIDs []string
|
||||||
|
AddFolder string
|
||||||
|
CustomLabelIDs []string
|
||||||
|
CustomFolderID string
|
||||||
|
ValidationAPIPlans []validationAPIPlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
|
||||||
|
// state labels, or a folder move to existing messages in batches of 20.
|
||||||
|
var MailMessageModify = common.Shortcut{
|
||||||
|
Service: "mail",
|
||||||
|
Command: "+message-modify",
|
||||||
|
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
|
||||||
|
Risk: "write",
|
||||||
|
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||||
|
ConditionalScopes: []string{
|
||||||
|
"mail:user_mailbox.folder:read",
|
||||||
|
},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||||
|
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
|
||||||
|
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
|
||||||
|
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
|
||||||
|
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
|
||||||
|
},
|
||||||
|
Validate: validateMessageModify,
|
||||||
|
DryRun: dryRunMessageModify,
|
||||||
|
Execute: executeMessageModify,
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
_, err := buildMessageModifyInput(rt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
input, _ := buildMessageModifyInput(rt)
|
||||||
|
api := common.NewDryRunAPI().
|
||||||
|
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
|
||||||
|
Set("batch_size", mailMessageManageBatchSize).
|
||||||
|
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
|
||||||
|
Set("validation_api_plan", input.ValidationAPIPlans)
|
||||||
|
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||||
|
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
|
||||||
|
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
input, err := buildMessageModifyInput(rt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
|
||||||
|
emitMessageManageSummary(rt, messageManageSummary{
|
||||||
|
SuccessMessageIDs: input.MessageIDs,
|
||||||
|
FailedMessageIDs: []messageManageFailure{},
|
||||||
|
}, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||||
|
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
|
||||||
|
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
|
||||||
|
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
|
||||||
|
if err != nil {
|
||||||
|
for _, id := range batch {
|
||||||
|
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||||
|
}
|
||||||
|
emitMessageManageSummary(rt, summary, false)
|
||||||
|
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||||
|
return mailFailedPreconditionError("all message modify batches failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
|
||||||
|
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
|
||||||
|
if err != nil {
|
||||||
|
return messageModifyInput{}, err
|
||||||
|
}
|
||||||
|
customLabels := append(customAddLabels, customRemoveLabels...)
|
||||||
|
customFolderID := ""
|
||||||
|
if customFolder {
|
||||||
|
customFolderID = folder
|
||||||
|
}
|
||||||
|
return messageModifyInput{
|
||||||
|
MessageIDs: messageIDs,
|
||||||
|
AddLabelIDs: addLabels,
|
||||||
|
RemoveLabelIDs: removeLabels,
|
||||||
|
AddFolder: folder,
|
||||||
|
CustomLabelIDs: customLabels,
|
||||||
|
CustomFolderID: customFolderID,
|
||||||
|
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
75
shortcuts/mail/mail_message_trash.go
Normal file
75
shortcuts/mail/mail_message_trash.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
|
||||||
|
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
|
||||||
|
// runner requires --yes before Execute.
|
||||||
|
var MailMessageTrash = common.Shortcut{
|
||||||
|
Service: "mail",
|
||||||
|
Command: "+message-trash",
|
||||||
|
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
|
||||||
|
Risk: "high-risk-write",
|
||||||
|
Scopes: []string{"mail:user_mailbox.message:modify"},
|
||||||
|
AuthTypes: []string{"user"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
|
||||||
|
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
|
||||||
|
},
|
||||||
|
Validate: validateMessageTrash,
|
||||||
|
DryRun: dryRunMessageTrash,
|
||||||
|
Execute: executeMessageTrash,
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
api := common.NewDryRunAPI().
|
||||||
|
Desc("Soft-delete messages sequentially in batches of 20").
|
||||||
|
Set("batch_size", mailMessageManageBatchSize).
|
||||||
|
Set("batches", chunkMessageManageIDs(messageIDs))
|
||||||
|
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||||
|
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
|
||||||
|
Body(map[string]interface{}{"message_ids": batch})
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
|
||||||
|
mailboxID := resolveMailboxID(rt)
|
||||||
|
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
|
||||||
|
for _, batch := range chunkMessageManageIDs(messageIDs) {
|
||||||
|
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
|
||||||
|
map[string]interface{}{"message_ids": batch})
|
||||||
|
if err != nil {
|
||||||
|
for _, id := range batch {
|
||||||
|
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
|
||||||
|
}
|
||||||
|
emitMessageManageSummary(rt, summary, false)
|
||||||
|
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
|
||||||
|
return mailFailedPreconditionError("all message trash batches failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
|
|||||||
RefreshToken: "test-refresh-token",
|
RefreshToken: "test-refresh-token",
|
||||||
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
|
||||||
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
|
||||||
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
|
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
|
||||||
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
|
||||||
}
|
}
|
||||||
if err := auth.SetStoredToken(token); err != nil {
|
if err := auth.SetStoredToken(token); err != nil {
|
||||||
|
|||||||
283
shortcuts/mail/message_manage_helpers.go
Normal file
283
shortcuts/mail/message_manage_helpers.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mailMessageManageBatchSize = 20
|
||||||
|
|
||||||
|
var messageManageSystemLabels = map[string]string{
|
||||||
|
"UNREAD": "UNREAD",
|
||||||
|
"IMPORTANT": "IMPORTANT",
|
||||||
|
"OTHER": "OTHER",
|
||||||
|
"FLAGGED": "FLAGGED",
|
||||||
|
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageManageSystemFolders = map[string]string{
|
||||||
|
"INBOX": "INBOX",
|
||||||
|
"SENT": "SENT",
|
||||||
|
"SPAM": "SPAM",
|
||||||
|
"ARCHIVE": "ARCHIVED",
|
||||||
|
"ARCHIVED": "ARCHIVED",
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageManageSummary struct {
|
||||||
|
SuccessMessageIDs []string `json:"success_message_ids"`
|
||||||
|
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type messageManageFailure struct {
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationAPIPlan struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
WillValidate bool `json:"will_validate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||||
|
}
|
||||||
|
parts, err := splitMessageManageIDTokens(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(parts))
|
||||||
|
seen := make(map[string]struct{}, len(parts))
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(part)
|
||||||
|
if id == "" {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
||||||
|
}
|
||||||
|
if id != part {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
||||||
|
}
|
||||||
|
if err := validateMessageManageID(id, i); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
||||||
|
parts := make([]string, 0, len(raw))
|
||||||
|
for i, token := range raw {
|
||||||
|
for _, r := range token {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||||
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, strings.Split(token, ",")...)
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageManageID(id string, index int) error {
|
||||||
|
if len(id) < 16 {
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
||||||
|
}
|
||||||
|
if strings.Trim(id, "0123456789") == "" {
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
||||||
|
}
|
||||||
|
for _, r := range id {
|
||||||
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
||||||
|
}
|
||||||
|
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch r {
|
||||||
|
case '+', '/', '=', '_', '-':
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
||||||
|
labels := make([]string, 0, len(raw))
|
||||||
|
custom := make([]string, 0, len(raw))
|
||||||
|
seen := make(map[string]struct{}, len(raw))
|
||||||
|
for i, part := range raw {
|
||||||
|
id := strings.TrimSpace(part)
|
||||||
|
if id == "" {
|
||||||
|
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
||||||
|
}
|
||||||
|
if id != part {
|
||||||
|
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
||||||
|
}
|
||||||
|
normalized := id
|
||||||
|
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
||||||
|
normalized = system
|
||||||
|
} else {
|
||||||
|
custom = append(custom, id)
|
||||||
|
}
|
||||||
|
if _, ok := seen[normalized]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = struct{}{}
|
||||||
|
labels = append(labels, normalized)
|
||||||
|
}
|
||||||
|
if len(labels) > 20 {
|
||||||
|
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
||||||
|
}
|
||||||
|
return labels, custom, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLabelIntersection(add, remove []string) error {
|
||||||
|
removeSet := make(map[string]struct{}, len(remove))
|
||||||
|
for _, id := range remove {
|
||||||
|
removeSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, id := range add {
|
||||||
|
if _, ok := removeSet[id]; ok {
|
||||||
|
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
||||||
|
if raw == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
folder := strings.TrimSpace(raw)
|
||||||
|
if folder == "" {
|
||||||
|
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
||||||
|
}
|
||||||
|
if folder != raw {
|
||||||
|
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(folder, "TRASH") {
|
||||||
|
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
||||||
|
}
|
||||||
|
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
||||||
|
return system, false, nil
|
||||||
|
}
|
||||||
|
return folder, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chunkMessageManageIDs(ids []string) [][]string {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
||||||
|
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
||||||
|
end := start + mailMessageManageBatchSize
|
||||||
|
if end > len(ids) {
|
||||||
|
end = len(ids)
|
||||||
|
}
|
||||||
|
chunks = append(chunks, ids[start:end])
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validateLabelReadScope(rt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
||||||
|
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := validateFolderReadScope(rt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
||||||
|
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
||||||
|
body := map[string]interface{}{"message_ids": ids}
|
||||||
|
if len(addLabels) > 0 {
|
||||||
|
body["add_label_ids"] = addLabels
|
||||||
|
}
|
||||||
|
if len(removeLabels) > 0 {
|
||||||
|
body["remove_label_ids"] = removeLabels
|
||||||
|
}
|
||||||
|
if addFolder != "" {
|
||||||
|
body["add_folder"] = addFolder
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
||||||
|
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
||||||
|
seenLabels := map[string]struct{}{}
|
||||||
|
for _, id := range customLabels {
|
||||||
|
if _, ok := seenLabels[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenLabels[id] = struct{}{}
|
||||||
|
plans = append(plans, validationAPIPlan{
|
||||||
|
Method: "GET",
|
||||||
|
Path: mailboxPath(mailboxID, "labels", id),
|
||||||
|
WillValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if customFolder != "" {
|
||||||
|
plans = append(plans, validationAPIPlan{
|
||||||
|
Method: "GET",
|
||||||
|
Path: mailboxPath(mailboxID, "folders", customFolder),
|
||||||
|
WillValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plans
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
||||||
|
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
||||||
|
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
||||||
|
if noAPICalls {
|
||||||
|
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
||||||
|
}
|
||||||
|
for _, item := range summary.FailedMessageIDs {
|
||||||
|
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
|
|||||||
return []common.Shortcut{
|
return []common.Shortcut{
|
||||||
MailMessage,
|
MailMessage,
|
||||||
MailMessages,
|
MailMessages,
|
||||||
|
MailMessageModify,
|
||||||
|
MailMessageTrash,
|
||||||
MailThread,
|
MailThread,
|
||||||
MailTriage,
|
MailTriage,
|
||||||
MailWatch,
|
MailWatch,
|
||||||
|
|||||||
@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
|
|||||||
case 90003087:
|
case 90003087:
|
||||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||||
case 1061003, 1061044:
|
case 1061003, 1061044:
|
||||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||||
case 1061004, 1062501:
|
case 1061004, 1062501:
|
||||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||||
|
case 1061101:
|
||||||
|
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
|
||||||
|
case 233523001:
|
||||||
|
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
|
||||||
|
case 99991400:
|
||||||
|
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
Tips: []string{
|
Tips: []string{
|
||||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||||
|
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return err
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
}
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
return validateMarkdownSpec(runtime, spec, true)
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}, true)
|
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
spec := markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return common.NewDryRunAPI().Set("error", err.Error())
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}
|
}
|
||||||
fileSize, err := markdownSourceSize(runtime, spec)
|
fileSize, err := markdownSourceSize(runtime, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
return dry
|
return dry
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
spec := markdownUploadSpec{
|
spec, err := readMarkdownCreateSpec(runtime)
|
||||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
if err != nil {
|
||||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
return err
|
||||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
|
||||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
|
||||||
FileSet: runtime.Changed("file"),
|
|
||||||
Content: runtime.Str("content"),
|
|
||||||
ContentSet: runtime.Changed("content"),
|
|
||||||
}
|
}
|
||||||
fileSize, err := markdownSourceSize(runtime, spec)
|
fileSize, err := markdownSourceSize(runtime, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||||
|
spec := markdownUploadSpec{
|
||||||
|
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||||
|
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||||
|
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||||
|
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||||
|
FileSet: runtime.Changed("file"),
|
||||||
|
Content: runtime.Str("content"),
|
||||||
|
ContentSet: runtime.Changed("content"),
|
||||||
|
}
|
||||||
|
return normalizeMarkdownCreateTargetSpec(spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||||
|
if spec.FolderToken != "" {
|
||||||
|
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||||
|
if err != nil {
|
||||||
|
return markdownUploadSpec{}, err
|
||||||
|
}
|
||||||
|
spec.FolderToken = token
|
||||||
|
}
|
||||||
|
if spec.WikiToken != "" {
|
||||||
|
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||||
|
if err != nil {
|
||||||
|
return markdownUploadSpec{}, err
|
||||||
|
}
|
||||||
|
spec.WikiToken = token
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if strings.Contains(token, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(token)
|
||||||
|
if !ok {
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||||
|
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||||
|
}
|
||||||
|
if ref.Type != "folder" {
|
||||||
|
return "", markdownValidationParamError("--folder-token",
|
||||||
|
"--folder-token must identify a Drive folder; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ref.Token, nil
|
||||||
|
}
|
||||||
|
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch markdownKnownResourceTokenKind(token) {
|
||||||
|
case "wiki":
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||||
|
WithHint("Pass it with --wiki-token instead.")
|
||||||
|
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||||
|
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if strings.Contains(token, "://") {
|
||||||
|
ref, ok := common.ParseResourceURL(token)
|
||||||
|
if !ok {
|
||||||
|
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||||
|
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||||
|
}
|
||||||
|
if ref.Type != "wiki" {
|
||||||
|
return "", markdownValidationParamError("--wiki-token",
|
||||||
|
"--wiki-token must identify a wiki node; got a %s URL",
|
||||||
|
ref.Type,
|
||||||
|
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return ref.Token, nil
|
||||||
|
}
|
||||||
|
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||||
|
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||||
|
}
|
||||||
|
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||||
|
if strings.ContainsAny(token, "/?#") {
|
||||||
|
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||||
|
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||||
|
if err := validate.ResourceName(token, flagName); err != nil {
|
||||||
|
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markdownKnownResourceTokenKind(token string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(token))
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(lower, "wik"):
|
||||||
|
return "wiki"
|
||||||
|
case strings.HasPrefix(lower, "docx"):
|
||||||
|
return "docx"
|
||||||
|
case strings.HasPrefix(lower, "doc"):
|
||||||
|
return "doc"
|
||||||
|
case strings.HasPrefix(lower, "sht"):
|
||||||
|
return "sheet"
|
||||||
|
case strings.HasPrefix(lower, "bas"):
|
||||||
|
return "bitable"
|
||||||
|
case strings.HasPrefix(lower, "mn"):
|
||||||
|
return "mindnote"
|
||||||
|
case strings.HasPrefix(lower, "sld"):
|
||||||
|
return "slides"
|
||||||
|
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||||
|
return "file"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||||
|
"--dry-run",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||||
|
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||||
|
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||||
|
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected folder-token URL type error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||||
|
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--name", "README.md",
|
||||||
|
"--content", "# hello",
|
||||||
|
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||||
|
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
run func() (string, error)
|
||||||
|
wantMsg string
|
||||||
|
wantHint string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "wiki token passed as folder token",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||||
|
wantMsg: "--folder-token looks like a wiki node token",
|
||||||
|
wantHint: "--wiki-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "folder token path fragment",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||||
|
wantMsg: "--folder-token must be a raw token",
|
||||||
|
wantHint: "full Lark URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "doc token passed as wiki token",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||||
|
wantMsg: "--wiki-token must be a wiki node token",
|
||||||
|
wantHint: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wiki token query fragment",
|
||||||
|
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||||
|
wantMsg: "--wiki-token must be a raw token",
|
||||||
|
wantHint: "path/query/fragment",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := tt.run()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected validation error")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||||
|
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||||
|
}
|
||||||
|
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||||
|
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if folderToken != "folder_token_raw" {
|
||||||
|
t.Fatalf("folder token = %q", folderToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||||
|
}
|
||||||
|
if wikiToken != "wik_placeholder_raw" {
|
||||||
|
t.Fatalf("wiki token = %q", wikiToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||||
|
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||||
|
p, ok := errs.ProblemOf(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||||
|
t.Fatalf("quota hint = %q", p.Hint)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||||
|
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||||
|
p, ok = errs.ProblemOf(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||||
|
}
|
||||||
|
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||||
|
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ import (
|
|||||||
const minutesDetailLogPrefix = "[minutes +detail]"
|
const minutesDetailLogPrefix = "[minutes +detail]"
|
||||||
|
|
||||||
// Error codes from the minutes API.
|
// Error codes from the minutes API.
|
||||||
const minutesDetailNoReadPermissionCode = 2091005
|
const (
|
||||||
|
minutesDetailProcessingCode = 2091003
|
||||||
|
minutesDetailNoReadPermissionCode = 2091005
|
||||||
|
minutesDetailWaitTimeoutDefault = 300
|
||||||
|
minutesDetailWaitIntervalDefault = 15
|
||||||
|
)
|
||||||
|
|
||||||
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
|
||||||
|
|
||||||
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
|
|||||||
// minuteDetailItem represents a single minute detail result.
|
// minuteDetailItem represents a single minute detail result.
|
||||||
type minuteDetailItem struct {
|
type minuteDetailItem struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
MinuteToken string `json:"minute_token"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Artifacts map[string]any `json:"artifacts,omitempty"`
|
Artifacts map[string]any `json:"artifacts,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
|
NextCommand string `json:"next_command,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
// fetchMinuteDetail queries a single minute's metadata and selected artifacts.
|
||||||
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet,
|
artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
waitReady := runtime.Bool("wait-ready")
|
||||||
|
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
|
||||||
|
|
||||||
|
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := &minuteDetailItem{MinuteToken: minuteToken}
|
result := &minuteDetailItem{MinuteToken: minuteToken}
|
||||||
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
|
||||||
|
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
|
||||||
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
|
||||||
} else {
|
} else {
|
||||||
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
result.Error = fmt.Sprintf("failed to query minute: %v", err)
|
||||||
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
needKeyword := runtime.Bool("keyword")
|
needKeyword := runtime.Bool("keyword")
|
||||||
|
|
||||||
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
if needSummary || needTodo || needChapter || needTranscript || needKeyword {
|
||||||
artData, err := runtime.CallAPITyped(http.MethodGet,
|
artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
return runtime.CallAPITyped(http.MethodGet,
|
||||||
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err)
|
if isMinutesDetailProcessingError(err) {
|
||||||
|
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
|
||||||
|
} else {
|
||||||
|
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
artifacts := make(map[string]any)
|
artifacts := make(map[string]any)
|
||||||
if needSummary {
|
if needSummary {
|
||||||
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isMinutesDetailProcessingError(err error) bool {
|
||||||
|
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
|
||||||
|
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
|
||||||
|
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
|
||||||
|
if timeoutSeconds <= 0 {
|
||||||
|
timeoutSeconds = minutesDetailWaitTimeoutDefault
|
||||||
|
}
|
||||||
|
if intervalSeconds <= 0 {
|
||||||
|
intervalSeconds = minutesDetailWaitIntervalDefault
|
||||||
|
}
|
||||||
|
return timeoutSeconds, intervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
data, err := call()
|
||||||
|
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||||
|
return nil, ctxErr
|
||||||
|
}
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 || interval > remaining {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
|
||||||
|
timer := time.NewTimer(interval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
|
||||||
|
var flags []string
|
||||||
|
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
|
||||||
|
if runtime.Bool(flag) {
|
||||||
|
flags = append(flags, "--"+flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
|
||||||
|
result.Status = "processing"
|
||||||
|
result.Retryable = true
|
||||||
|
result.Error = reason
|
||||||
|
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
|
||||||
|
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
|
||||||
|
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
|
||||||
|
parts = append(parts, artifactFlags...)
|
||||||
|
parts = append(parts, "--wait-ready")
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
// saveDetailTranscript persists transcript bytes to the canonical artifact path.
|
||||||
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
|
||||||
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
|
||||||
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
|
|||||||
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
{Name: "keyword", Type: "bool", Desc: "include keywords"},
|
||||||
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
|
||||||
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
|
||||||
|
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
|
||||||
|
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
|
||||||
|
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
|
||||||
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
|
|||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
row := map[string]interface{}{"minute_token": r.MinuteToken}
|
||||||
if r.Error != "" {
|
if r.Error != "" {
|
||||||
row["status"] = "FAIL"
|
if r.Status == "processing" {
|
||||||
|
row["status"] = "PROCESSING"
|
||||||
|
} else {
|
||||||
|
row["status"] = "FAIL"
|
||||||
|
}
|
||||||
row["error"] = r.Error
|
row["error"] = r.Error
|
||||||
|
if r.NextCommand != "" {
|
||||||
|
row["next_command"] = r.NextCommand
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
row["status"] = "OK"
|
row["status"] = "OK"
|
||||||
row["title"] = r.Title
|
row["title"] = r.Title
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func detailProcessingStub(path string) *httpmock.Stub {
|
||||||
|
return &httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: path,
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 2091003,
|
||||||
|
"msg": "minute is processing",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
|
||||||
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_HiddenWaitFlags(t *testing.T) {
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
parent := &cobra.Command{Use: "minutes"}
|
||||||
|
MinutesDetail.Mount(parent, f)
|
||||||
|
parent.SetOut(stdout)
|
||||||
|
parent.SetArgs([]string{"+detail", "--help"})
|
||||||
|
parent.SilenceErrors = true
|
||||||
|
parent.SilenceUsage = true
|
||||||
|
if err := parent.Execute(); err != nil {
|
||||||
|
t.Fatalf("help failed: %v", err)
|
||||||
|
}
|
||||||
|
help := stdout.String()
|
||||||
|
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
|
||||||
|
if strings.Contains(help, hidden) {
|
||||||
|
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hidden wait flags should parse: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Execute tests with mocked HTTP
|
// Execute tests with mocked HTTP
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
var pfErr *output.PartialFailureError
|
||||||
|
if !errors.As(err, &pfErr) {
|
||||||
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["retryable"] != true {
|
||||||
|
t.Fatalf("retryable = %v, want true", m["retryable"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" {
|
||||||
|
t.Fatalf("status = %v, want processing", m["status"])
|
||||||
|
}
|
||||||
|
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
|
||||||
|
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
|
||||||
|
t.Fatalf("next_command = %v", m["next_command"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["title"] != "Ready Metadata" {
|
||||||
|
t.Fatalf("title = %v, want Ready Metadata", m["title"])
|
||||||
|
}
|
||||||
|
if _, ok := m["artifacts"]; ok {
|
||||||
|
t.Fatal("artifacts should not be fetched without artifact flags")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
|
||||||
|
reg.Register(detailArtifactsStub("tokwaitart", ""))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
arts, _ := m["artifacts"].(map[string]any)
|
||||||
|
if arts == nil {
|
||||||
|
t.Fatal("expected artifacts")
|
||||||
|
}
|
||||||
|
if arts["summary"] != "Test summary content" {
|
||||||
|
t.Fatalf("summary = %v", arts["summary"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
|
||||||
|
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
m := firstDetailMinute(t, stdout.Bytes())
|
||||||
|
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
|
||||||
|
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
var callCount int
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "GET",
|
||||||
|
URL: "/open-apis/minutes/v1/minutes/tokmissing",
|
||||||
|
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
|
||||||
|
Reusable: true,
|
||||||
|
OnMatch: func(req *http.Request) { callCount++ },
|
||||||
|
})
|
||||||
|
|
||||||
|
err := detailMountAndRun(t, MinutesDetail, []string{
|
||||||
|
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
|
||||||
|
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
|
||||||
|
}, f, stdout)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected partial failure error")
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pure function tests
|
// Pure function tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
|
||||||
|
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
|
||||||
|
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
|
||||||
|
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
|
||||||
|
}
|
||||||
|
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
|
||||||
|
if timeout != 9 || interval != 3 {
|
||||||
|
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
|
||||||
|
}
|
||||||
|
data, _ := resp["data"].(map[string]any)
|
||||||
|
minutes, _ := data["minutes"].([]any)
|
||||||
|
if len(minutes) != 1 {
|
||||||
|
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
|
||||||
|
}
|
||||||
|
m, _ := minutes[0].(map[string]any)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// chdirForDetailTest switches cwd to a temp dir for the test.
|
// chdirForDetailTest switches cwd to a temp dir for the test.
|
||||||
func chdirForDetailTest(t *testing.T) string {
|
func chdirForDetailTest(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
dr := common.NewDryRunAPI()
|
return common.NewDryRunAPI().
|
||||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
|
||||||
}
|
|
||||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
|
||||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||||
|
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||||
|
|
||||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"minute_token": minuteToken,
|
"minute_token": minuteToken,
|
||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
}
|
}
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
out["from_speaker_id"] = fromSpeakerID
|
out["from_speaker_id"] = fromSpeakerID
|
||||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
|
||||||
out["from_speaker_input"] = fromSpeakerInput
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
out["from_user_id"] = fromUserID
|
out["from_user_id"] = fromUserID
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||||
if fromSpeakerInput != "" {
|
|
||||||
return fromSpeakerInput
|
|
||||||
}
|
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
return fromSpeakerID
|
return fromSpeakerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
||||||
warmTokenCache(t)
|
|
||||||
|
|
||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
|
||||||
"+speaker-replace",
|
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run", "--as", "user",
|
|
||||||
}, f, stdout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
if !strings.Contains(out, "GET") {
|
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
|
||||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "PUT") {
|
|
||||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "ou_new_speaker") {
|
|
||||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
warmTokenCache(t)
|
warmTokenCache(t)
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||||
Method: http.MethodGet,
|
// straight through without a second speakerlist call. If the code still
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"speakers": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
|
||||||
"name": "说话人1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||||
"+speaker-replace",
|
"+speaker-replace",
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||||
"--from-speaker-id", "说话人1",
|
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||||
"--to-user-id", "ou_new_speaker",
|
"--to-user-id", "ou_new_speaker",
|
||||||
"--format", "json", "--as", "user",
|
"--format", "json", "--as", "user",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
FromSpeakerID string `json:"from_speaker_id"`
|
||||||
FromSpeakerInput string `json:"from_speaker_input"`
|
ToUserID string `json:"to_user_id"`
|
||||||
FromSpeakerID string `json:"from_speaker_id"`
|
|
||||||
ToUserID string `json:"to_user_id"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
t.Fatalf("unmarshal stdout: %v", err)
|
t.Fatalf("unmarshal stdout: %v", err)
|
||||||
}
|
}
|
||||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
|
||||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
|
||||||
}
|
|
||||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||||
}
|
}
|
||||||
|
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||||
|
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "GET") {
|
if strings.Contains(out, "/transcript/speakerlist") {
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "PUT") {
|
||||||
|
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
type minuteSpeaker struct {
|
|
||||||
SpeakerID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
|
||||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if data == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := common.GetSlice(data, "speakers")
|
|
||||||
speakers := make([]minuteSpeaker, 0, len(items))
|
|
||||||
for _, raw := range items {
|
|
||||||
item, _ := raw.(map[string]interface{})
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
|
||||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
|
||||||
}
|
|
||||||
return speakers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
var matches []minuteSpeaker
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.Name == name {
|
|
||||||
matches = append(matches, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch len(matches) {
|
|
||||||
case 0:
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
|
||||||
"no speaker named %q in minute transcript", name).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
|
||||||
case 1:
|
|
||||||
return matches[0].SpeakerID, nil
|
|
||||||
default:
|
|
||||||
ids := make([]string, len(matches))
|
|
||||||
for i, m := range matches {
|
|
||||||
ids[i] = m.SpeakerID
|
|
||||||
}
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
|
||||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
|
||||||
// The input may already be an opaque speaker_id, or a display name that requires
|
|
||||||
// an internal speaker-list fetch.
|
|
||||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.SpeakerID == input {
|
|
||||||
return input, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolveSpeakerIDByName(speakers, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
|
||||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
|
||||||
if fromUserID != "" {
|
|
||||||
return "", fromUserID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
|
||||||
return fromSpeakerID, "", err
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
|
||||||
speakers := []minuteSpeaker{
|
|
||||||
{SpeakerID: "id_a", Name: "Alice"},
|
|
||||||
{SpeakerID: "id_b", Name: "Bob"},
|
|
||||||
{SpeakerID: "id_c", Name: "Alice"},
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
|
||||||
if err != nil || id != "id_b" {
|
|
||||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected not found error")
|
|
||||||
}
|
|
||||||
var ve *errs.ValidationError
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
|
||||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected duplicate name error")
|
|
||||||
}
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
|
||||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
|
||||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ package minutes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
|
|||||||
outData := map[string]interface{}{
|
outData := map[string]interface{}{
|
||||||
"minute_url": minuteURL,
|
"minute_url": minuteURL,
|
||||||
}
|
}
|
||||||
|
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
|
||||||
|
outData["minute_token"] = minuteToken
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(outData, nil, nil)
|
runtime.OutFormat(outData, nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractUploadedMinuteToken(minuteURL string) string {
|
||||||
|
u, err := url.Parse(minuteURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "minutes" && i+1 < len(parts) {
|
||||||
|
return parts[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
|
|||||||
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
|
||||||
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
|
||||||
}
|
}
|
||||||
|
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
|
||||||
|
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUploadedMinuteToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
|
||||||
|
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
|
||||||
|
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
|
||||||
|
{name: "invalid", url: "://bad", want: ""},
|
||||||
|
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
|
||||||
|
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
|||||||
return objectives, nil
|
return objectives, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContentBlock converts text and mentions to a ContentBlock.
|
|
||||||
func buildContentBlock(text string, mentions []string) *ContentBlock {
|
|
||||||
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
|
|
||||||
|
|
||||||
// Add text element
|
|
||||||
textElem := ContentParagraphElement{
|
|
||||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
|
||||||
TextRun: &ContentTextRun{
|
|
||||||
Text: &text,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
elements = append(elements, textElem)
|
|
||||||
|
|
||||||
// Add mention elements
|
|
||||||
for _, mention := range mentions {
|
|
||||||
mentionElem := ContentParagraphElement{
|
|
||||||
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
|
||||||
Mention: &ContentMention{
|
|
||||||
UserID: &mention,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
elements = append(elements, mentionElem)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ContentBlock{
|
|
||||||
Blocks: []ContentBlockElement{
|
|
||||||
{
|
|
||||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
|
||||||
Paragraph: &ContentParagraph{
|
|
||||||
Elements: elements,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createObjective calls the API to create an objective.
|
// createObjective calls the API to create an objective.
|
||||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||||
content := buildContentBlock(obj.Text, obj.Mention)
|
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
|
|||||||
|
|
||||||
// createKR calls the API to create a key result.
|
// createKR calls the API to create a key result.
|
||||||
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
|
||||||
content := buildContentBlock(kr.Text, kr.Mention)
|
content := BuildContentBlock(kr.Text, kr.Mention)
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"content": content,
|
"content": content,
|
||||||
}
|
}
|
||||||
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
|
|||||||
|
|
||||||
for i, obj := range objectives {
|
for i, obj := range objectives {
|
||||||
// Objective creation
|
// Objective creation
|
||||||
objContent := buildContentBlock(obj.Text, obj.Mention)
|
objContent := BuildContentBlock(obj.Text, obj.Mention)
|
||||||
objBody := map[string]interface{}{
|
objBody := map[string]interface{}{
|
||||||
"content": objContent,
|
"content": objContent,
|
||||||
}
|
}
|
||||||
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
|
|||||||
|
|
||||||
// KR creations
|
// KR creations
|
||||||
for j, kr := range obj.KRs {
|
for j, kr := range obj.KRs {
|
||||||
krContent := buildContentBlock(kr.Text, kr.Mention)
|
krContent := BuildContentBlock(kr.Text, kr.Mention)
|
||||||
krBody := map[string]interface{}{
|
krBody := map[string]interface{}{
|
||||||
"content": krContent,
|
"content": krContent,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildContentBlock(t *testing.T) {
|
func TestBuildContentBlock(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||||
if cb == nil {
|
if cb == nil {
|
||||||
t.Fatal("expected non-nil ContentBlock")
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,10 @@ type RespCategory struct {
|
|||||||
|
|
||||||
// RespCycle 周期
|
// RespCycle 周期
|
||||||
type RespCycle struct {
|
type RespCycle struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreateTime string `json:"create_time"`
|
StartTime string `json:"start_time"`
|
||||||
UpdateTime string `json:"update_time"`
|
EndTime string `json:"end_time"`
|
||||||
TenantCycleID string `json:"tenant_cycle_id"`
|
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||||
Owner RespOwner `json:"owner"`
|
|
||||||
StartTime string `json:"start_time"`
|
|
||||||
EndTime string `json:"end_time"`
|
|
||||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
|
||||||
Score *float64 `json:"score,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespIndicator 指标
|
// RespIndicator 指标
|
||||||
@@ -152,3 +147,145 @@ type RespProgress struct {
|
|||||||
Content *string `json:"content,omitempty"`
|
Content *string `json:"content,omitempty"`
|
||||||
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Simple-style response types (semi-plain text format) ==========
|
||||||
|
|
||||||
|
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
|
||||||
|
type RespKeyResultSimple struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
UpdateTime string `json:"update_time"`
|
||||||
|
Owner RespOwner `json:"owner"`
|
||||||
|
ObjectiveID string `json:"objective_id"`
|
||||||
|
Position *int32 `json:"position,omitempty"`
|
||||||
|
Content *SemiPlainContent `json:"content,omitempty"`
|
||||||
|
Score *float64 `json:"score,omitempty"`
|
||||||
|
Weight *float64 `json:"weight,omitempty"`
|
||||||
|
Deadline *string `json:"deadline,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
|
||||||
|
type RespObjectiveSimple struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreateTime string `json:"create_time"`
|
||||||
|
UpdateTime string `json:"update_time"`
|
||||||
|
Owner RespOwner `json:"owner"`
|
||||||
|
CycleID string `json:"cycle_id"`
|
||||||
|
Position *int32 `json:"position,omitempty"`
|
||||||
|
Content *SemiPlainContent `json:"content,omitempty"`
|
||||||
|
Score *float64 `json:"score,omitempty"`
|
||||||
|
Notes *SemiPlainContent `json:"notes,omitempty"`
|
||||||
|
Weight *float64 `json:"weight,omitempty"`
|
||||||
|
Deadline *string `json:"deadline,omitempty"`
|
||||||
|
CategoryID *string `json:"category_id,omitempty"`
|
||||||
|
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
|
||||||
|
type RespProgressSimple struct {
|
||||||
|
ID string `json:"progress_id"`
|
||||||
|
ModifyTime string `json:"modify_time"`
|
||||||
|
CreateTime *string `json:"create_time,omitempty"`
|
||||||
|
Content *SemiPlainContent `json:"content,omitempty"`
|
||||||
|
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSimple converts KeyResult to RespKeyResultSimple.
|
||||||
|
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
|
||||||
|
if k == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &RespKeyResultSimple{
|
||||||
|
ID: k.ID,
|
||||||
|
CreateTime: formatTimestamp(k.CreateTime),
|
||||||
|
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||||
|
Owner: *k.Owner.ToResp(),
|
||||||
|
ObjectiveID: k.ObjectiveID,
|
||||||
|
Position: k.Position,
|
||||||
|
Score: k.Score,
|
||||||
|
Weight: k.Weight,
|
||||||
|
}
|
||||||
|
if k.Deadline != nil {
|
||||||
|
d := formatTimestamp(*k.Deadline)
|
||||||
|
result.Deadline = &d
|
||||||
|
}
|
||||||
|
result.Content = k.Content.ToSemiPlain()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSimple converts Objective to RespObjectiveSimple.
|
||||||
|
func (o *Objective) ToSimple() *RespObjectiveSimple {
|
||||||
|
if o == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &RespObjectiveSimple{
|
||||||
|
ID: o.ID,
|
||||||
|
CreateTime: formatTimestamp(o.CreateTime),
|
||||||
|
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||||
|
Owner: *o.Owner.ToResp(),
|
||||||
|
CycleID: o.CycleID,
|
||||||
|
Position: o.Position,
|
||||||
|
Score: o.Score,
|
||||||
|
Weight: o.Weight,
|
||||||
|
CategoryID: o.CategoryID,
|
||||||
|
}
|
||||||
|
if o.Deadline != nil {
|
||||||
|
d := formatTimestamp(*o.Deadline)
|
||||||
|
result.Deadline = &d
|
||||||
|
}
|
||||||
|
result.Content = o.Content.ToSemiPlain()
|
||||||
|
result.Notes = o.Notes.ToSemiPlain()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSimple converts ProgressV1 to RespProgressSimple.
|
||||||
|
func (p *ProgressV1) ToSimple() *RespProgressSimple {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
resp := &RespProgressSimple{
|
||||||
|
ID: p.ID,
|
||||||
|
ModifyTime: formatTimestamp(p.ModifyTime),
|
||||||
|
}
|
||||||
|
if p.ProgressRate != nil {
|
||||||
|
resp.ProgressRate = &RespProgressRate{
|
||||||
|
Percent: p.ProgressRate.Percent,
|
||||||
|
}
|
||||||
|
if p.ProgressRate.Status != nil {
|
||||||
|
s := ProgressStatus(*p.ProgressRate.Status).String()
|
||||||
|
if s != "" {
|
||||||
|
resp.ProgressRate.Status = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Content != nil {
|
||||||
|
resp.Content = p.Content.ToV2().ToSemiPlain()
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSimple converts Progress to RespProgressSimple.
|
||||||
|
func (p *Progress) ToSimple() *RespProgressSimple {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
createTime := formatTimestamp(p.CreateTime)
|
||||||
|
resp := &RespProgressSimple{
|
||||||
|
ID: p.ID,
|
||||||
|
ModifyTime: formatTimestamp(p.UpdateTime),
|
||||||
|
CreateTime: &createTime,
|
||||||
|
}
|
||||||
|
if p.ProgressRate != nil {
|
||||||
|
resp.ProgressRate = &RespProgressRate{
|
||||||
|
Percent: p.ProgressRate.ProgressPercent,
|
||||||
|
}
|
||||||
|
if p.ProgressRate.ProgressStatus != nil {
|
||||||
|
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
|
||||||
|
if s != "" {
|
||||||
|
resp.ProgressRate.Status = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.Content = p.Content.ToSemiPlain()
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||||
|
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
cycleID := runtime.Str("cycle-id")
|
cycleID := runtime.Str("cycle-id")
|
||||||
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||||
}
|
}
|
||||||
|
style := runtime.Str("style")
|
||||||
|
if style != "simple" && style != "richtext" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
cycleID := runtime.Str("cycle-id")
|
cycleID := runtime.Str("cycle-id")
|
||||||
|
style := runtime.Str("style")
|
||||||
|
|
||||||
// Paginate objectives under the cycle.
|
// Paginate objectives under the cycle.
|
||||||
queryParams := map[string]interface{}{"page_size": "100"}
|
queryParams := map[string]interface{}{"page_size": "100"}
|
||||||
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For each objective, paginate key results and convert to response format.
|
// For each objective, paginate key results and convert to response format.
|
||||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
if style == "simple" {
|
||||||
for i := range objectives {
|
respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
|
||||||
if err := ctx.Err(); err != nil {
|
for i := range objectives {
|
||||||
return err
|
|
||||||
}
|
|
||||||
obj := &objectives[i]
|
|
||||||
|
|
||||||
krQuery := map[string]interface{}{"page_size": "100"}
|
|
||||||
|
|
||||||
var keyResults []KeyResult
|
|
||||||
krPage := 0
|
|
||||||
for {
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if krPage > 0 {
|
obj := &objectives[i]
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
krPage++
|
|
||||||
|
|
||||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||||
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
itemsRaw, _ := data["items"].([]interface{})
|
respObj := obj.ToSimple()
|
||||||
for _, item := range itemsRaw {
|
if respObj == nil {
|
||||||
raw, err := json.Marshal(item)
|
continue
|
||||||
if err != nil {
|
}
|
||||||
continue
|
respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
|
||||||
|
for j := range keyResults {
|
||||||
|
if r := keyResults[j].ToSimple(); r != nil {
|
||||||
|
respKRs = append(respKRs, *r)
|
||||||
}
|
}
|
||||||
var kr KeyResult
|
}
|
||||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
respObj.KeyResults = respKRs
|
||||||
continue
|
respObjectives = append(respObjectives, respObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"cycle_id": cycleID,
|
||||||
|
"objectives": respObjectives,
|
||||||
|
"total": len(respObjectives),
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||||
|
for _, o := range respObjectives {
|
||||||
|
contentText := ""
|
||||||
|
if o.Content != nil {
|
||||||
|
contentText = o.Content.Text
|
||||||
}
|
}
|
||||||
keyResults = append(keyResults, kr)
|
notesText := ""
|
||||||
|
if o.Notes != nil {
|
||||||
|
notesText = o.Notes.Text
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||||
|
for _, kr := range o.KeyResults {
|
||||||
|
krText := ""
|
||||||
|
if kr.Content != nil {
|
||||||
|
krText = kr.Content.Text
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// richtext mode
|
||||||
|
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||||
|
for i := range objectives {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
obj := &objectives[i]
|
||||||
|
|
||||||
|
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
hasMore, pageToken := common.PaginationMeta(data)
|
respObj := obj.ToResp()
|
||||||
if !hasMore || pageToken == "" {
|
if respObj == nil {
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
krQuery["page_token"] = pageToken
|
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||||
|
for j := range keyResults {
|
||||||
|
if r := keyResults[j].ToResp(); r != nil {
|
||||||
|
respKRs = append(respKRs, *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respObj.KeyResults = respKRs
|
||||||
|
respObjectives = append(respObjectives, respObj)
|
||||||
}
|
}
|
||||||
|
|
||||||
respObj := obj.ToResp()
|
result := map[string]interface{}{
|
||||||
if respObj == nil {
|
"cycle_id": cycleID,
|
||||||
continue
|
"objectives": respObjectives,
|
||||||
|
"total": len(respObjectives),
|
||||||
|
"style": style,
|
||||||
}
|
}
|
||||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
|
||||||
for j := range keyResults {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
if r := keyResults[j].ToResp(); r != nil {
|
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
|
||||||
respKRs = append(respKRs, *r)
|
for _, o := range respObjectives {
|
||||||
|
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||||
|
for _, kr := range o.KeyResults {
|
||||||
|
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
respObj.KeyResults = respKRs
|
|
||||||
respObjectives = append(respObjectives, respObj)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"cycle_id": cycleID,
|
|
||||||
"objectives": respObjectives,
|
|
||||||
"total": len(respObjectives),
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
|
||||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
|
||||||
for _, o := range respObjectives {
|
|
||||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
|
||||||
for _, kr := range o.KeyResults {
|
|
||||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
|||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
cycleStart := time.UnixMilli(startMs)
|
cycleStart := time.UnixMilli(startMs).UTC()
|
||||||
cycleEnd := time.UnixMilli(endMs)
|
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||||
// Two ranges overlap iff one starts before the other ends
|
// Two ranges overlap iff one starts before the other ends
|
||||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isCurrentActiveCycle checks whether a cycle is currently active:
|
||||||
|
// - current time is within the cycle's start and end time
|
||||||
|
// - cycle status is default (0) or normal (1)
|
||||||
|
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||||
|
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||||
|
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cycleStart := time.UnixMilli(startMs).UTC()
|
||||||
|
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||||
|
nowUTC := now.UTC()
|
||||||
|
|
||||||
|
// Check time range: now must be >= start and <= end
|
||||||
|
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status: must be default or normal
|
||||||
|
if cycle.CycleStatus == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
status := *cycle.CycleStatus
|
||||||
|
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||||
|
}
|
||||||
|
|
||||||
var OKRListCycles = common.Shortcut{
|
var OKRListCycles = common.Shortcut{
|
||||||
Service: "okr",
|
Service: "okr",
|
||||||
Command: "+cycle-list",
|
Command: "+cycle-list",
|
||||||
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
|
|||||||
respCycles = append(respCycles, filtered[i].ToResp())
|
respCycles = append(respCycles, filtered[i].ToResp())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter current active cycles
|
||||||
|
now := time.Now()
|
||||||
|
currentActiveCycles := make([]*RespCycle, 0)
|
||||||
|
for i := range filtered {
|
||||||
|
if isCurrentActiveCycle(&filtered[i], now) {
|
||||||
|
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(map[string]interface{}{
|
runtime.OutFormat(map[string]interface{}{
|
||||||
"cycles": respCycles,
|
"cycles": respCycles,
|
||||||
"total": len(respCycles),
|
"total": len(respCycles),
|
||||||
|
"current_active_cycles": currentActiveCycles,
|
||||||
}, nil, func(w io.Writer) {
|
}, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||||
for _, c := range respCycles {
|
for _, c := range respCycles {
|
||||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||||
}
|
}
|
||||||
|
if len(currentActiveCycles) > 0 {
|
||||||
|
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
|
||||||
|
for _, c := range currentActiveCycles {
|
||||||
|
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
|
|||||||
if len(cycles) != 0 {
|
if len(cycles) != 0 {
|
||||||
t.Fatalf("cycles = %v, want empty", cycles)
|
t.Fatalf("cycles = %v, want empty", cycles)
|
||||||
}
|
}
|
||||||
|
// Assert current_active_cycles field exists and is a slice
|
||||||
|
rawCurrentActive, ok := data["current_active_cycles"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("current_active_cycles field is missing from response")
|
||||||
|
}
|
||||||
|
currentActive, ok := rawCurrentActive.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||||
|
}
|
||||||
|
if len(currentActive) != 0 {
|
||||||
|
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- isCurrentActiveCycle unit tests ---
|
||||||
|
|
||||||
|
func TestIsCurrentActiveCycle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cycle *Cycle
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "active cycle with normal status",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c1",
|
||||||
|
StartTime: "1767225600000", // 2026-01-01
|
||||||
|
EndTime: "1798761599999", // 2026-12-31 23:59:59
|
||||||
|
CycleStatus: CycleStatusNormal.Ptr(),
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "active cycle with default status",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c2",
|
||||||
|
StartTime: "1767225600000", // 2026-01-01
|
||||||
|
EndTime: "1798761599999", // 2026-12-31
|
||||||
|
CycleStatus: CycleStatusDefault.Ptr(),
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle with invalid status",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c3",
|
||||||
|
StartTime: "1767225600000", // 2026-01-01
|
||||||
|
EndTime: "1798761599999", // 2026-12-31
|
||||||
|
CycleStatus: CycleStatusInvalid.Ptr(),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle with hidden status",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c4",
|
||||||
|
StartTime: "1767225600000", // 2026-01-01
|
||||||
|
EndTime: "1798761599999", // 2026-12-31
|
||||||
|
CycleStatus: CycleStatusHidden.Ptr(),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "past cycle",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c5",
|
||||||
|
StartTime: "1704067200000", // 2024-01-01
|
||||||
|
EndTime: "1719791999999", // 2024-06-30
|
||||||
|
CycleStatus: CycleStatusNormal.Ptr(),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future cycle",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c6",
|
||||||
|
StartTime: "1830297600000", // 2028-01-01
|
||||||
|
EndTime: "1861833599999", // 2028-12-31
|
||||||
|
CycleStatus: CycleStatusNormal.Ptr(),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil cycle status",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c7",
|
||||||
|
StartTime: "1767225600000", // 2026-01-01
|
||||||
|
EndTime: "1798761599999", // 2026-12-31
|
||||||
|
CycleStatus: nil,
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid start time",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c8",
|
||||||
|
StartTime: "invalid",
|
||||||
|
EndTime: "1798761599999", // 2026-12-31
|
||||||
|
CycleStatus: CycleStatusNormal.Ptr(),
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact start time boundary",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c9",
|
||||||
|
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||||
|
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
|
||||||
|
CycleStatus: CycleStatusNormal.Ptr(),
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact end time boundary",
|
||||||
|
cycle: &Cycle{
|
||||||
|
ID: "c10",
|
||||||
|
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
|
||||||
|
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
|
||||||
|
CycleStatus: CycleStatusNormal.Ptr(),
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isCurrentActiveCycle(tt.cycle, now)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||||
|
|
||||||
|
// Calculate timestamps relative to now to avoid test expiration
|
||||||
|
now := time.Now().UTC()
|
||||||
|
// Active cycle: 6 months before to 6 months after now
|
||||||
|
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
|
||||||
|
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
|
||||||
|
// Past cycle: 2 years before to 1.5 years before now
|
||||||
|
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
|
||||||
|
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
URL: "/open-apis/okr/v2/cycles",
|
URL: "/open-apis/okr/v2/cycles",
|
||||||
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
|||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"items": []interface{}{
|
"items": []interface{}{
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"id": "cycle-1",
|
"id": "cycle-active",
|
||||||
"start_time": "1735689600000",
|
"start_time": strconv.FormatInt(activeStartMs, 10),
|
||||||
"end_time": "1751318400000",
|
"end_time": strconv.FormatInt(activeEndMs, 10),
|
||||||
"cycle_status": 1,
|
"cycle_status": 1, // normal
|
||||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||||
"tenant_cycle_id": "tc-1",
|
"tenant_cycle_id": "tc-1",
|
||||||
"score": 0.75,
|
"score": 0.75,
|
||||||
},
|
},
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"id": "cycle-2",
|
"id": "cycle-past",
|
||||||
"start_time": "1704067200000",
|
"start_time": strconv.FormatInt(pastStartMs, 10),
|
||||||
"end_time": "1719792000000",
|
"end_time": strconv.FormatInt(pastEndMs, 10),
|
||||||
"cycle_status": 2,
|
"cycle_status": 2, // invalid
|
||||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||||
"tenant_cycle_id": "tc-2",
|
"tenant_cycle_id": "tc-2",
|
||||||
"score": 0.5,
|
"score": 0.5,
|
||||||
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
|||||||
if int(total) != 2 {
|
if int(total) != 2 {
|
||||||
t.Fatalf("total = %v, want 2", total)
|
t.Fatalf("total = %v, want 2", total)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check current_active_cycles - should only contain cycle-active
|
||||||
|
rawCurrentActive, ok := data["current_active_cycles"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("current_active_cycles field is missing from response")
|
||||||
|
}
|
||||||
|
currentActive, ok := rawCurrentActive.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
|
||||||
|
}
|
||||||
|
if len(currentActive) != 1 {
|
||||||
|
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
|
||||||
|
}
|
||||||
|
activeCycle, ok := currentActive[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
|
||||||
|
}
|
||||||
|
if activeCycle["id"] != "cycle-active" {
|
||||||
|
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify removed fields are not present in the response
|
||||||
|
for _, c := range cycles {
|
||||||
|
cycleMap, _ := c.(map[string]interface{})
|
||||||
|
if _, ok := cycleMap["create_time"]; ok {
|
||||||
|
t.Fatal("create_time should not be present in response")
|
||||||
|
}
|
||||||
|
if _, ok := cycleMap["update_time"]; ok {
|
||||||
|
t.Fatal("update_time should not be present in response")
|
||||||
|
}
|
||||||
|
if _, ok := cycleMap["tenant_cycle_id"]; ok {
|
||||||
|
t.Fatal("tenant_cycle_id should not be present in response")
|
||||||
|
}
|
||||||
|
if _, ok := cycleMap["owner"]; ok {
|
||||||
|
t.Fatal("owner should not be present in response")
|
||||||
|
}
|
||||||
|
if _, ok := cycleMap["score"]; ok {
|
||||||
|
t.Fatal("score should not be present in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
resp := &RespCycle{
|
resp := &RespCycle{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
CreateTime: formatTimestamp(c.CreateTime),
|
StartTime: formatTimestamp(c.StartTime),
|
||||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
EndTime: formatTimestamp(c.EndTime),
|
||||||
TenantCycleID: c.TenantCycleID,
|
|
||||||
Owner: *c.Owner.ToResp(),
|
|
||||||
StartTime: formatTimestamp(c.StartTime),
|
|
||||||
EndTime: formatTimestamp(c.EndTime),
|
|
||||||
Score: c.Score,
|
|
||||||
}
|
}
|
||||||
if c.CycleStatus != nil {
|
if c.CycleStatus != nil {
|
||||||
s := c.CycleStatus.ToString()
|
s := c.CycleStatus.ToString()
|
||||||
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== SemiPlainContent (半纯文本格式) ==========
|
||||||
|
|
||||||
|
// Regex patterns for semi-plain text processing (pre-compiled for performance).
|
||||||
|
var (
|
||||||
|
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
|
||||||
|
multiSpaceRE = regexp.MustCompile(`\s+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// SemiPlainDoc represents a document link in semi-plain content.
|
||||||
|
type SemiPlainDoc struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
|
||||||
|
// It contains plain text, mentions, docs, and images without rich formatting or position info.
|
||||||
|
type SemiPlainContent struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Mention []string `json:"mention,omitempty"`
|
||||||
|
Docs []SemiPlainDoc `json:"docs,omitempty"`
|
||||||
|
Images []string `json:"images,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
|
||||||
|
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
|
||||||
|
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := &SemiPlainContent{}
|
||||||
|
var textParts []string
|
||||||
|
|
||||||
|
for _, block := range c.Blocks {
|
||||||
|
if block.Paragraph != nil {
|
||||||
|
for _, elem := range block.Paragraph.Elements {
|
||||||
|
switch {
|
||||||
|
case elem.TextRun != nil && elem.TextRun.Text != nil:
|
||||||
|
textParts = append(textParts, *elem.TextRun.Text)
|
||||||
|
case elem.Mention != nil && elem.Mention.UserID != nil:
|
||||||
|
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
|
||||||
|
result.Mention = append(result.Mention, *elem.Mention.UserID)
|
||||||
|
case elem.DocsLink != nil:
|
||||||
|
doc := SemiPlainDoc{}
|
||||||
|
if elem.DocsLink.Title != nil {
|
||||||
|
doc.Title = *elem.DocsLink.Title
|
||||||
|
}
|
||||||
|
if elem.DocsLink.URL != nil {
|
||||||
|
doc.URL = *elem.DocsLink.URL
|
||||||
|
}
|
||||||
|
result.Docs = append(result.Docs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if block.Gallery != nil {
|
||||||
|
for _, img := range block.Gallery.Images {
|
||||||
|
if img.Src != nil {
|
||||||
|
result.Images = append(result.Images, *img.Src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Text = strings.Join(textParts, "")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToContentBlock converts SemiPlainContent to ContentBlock.
|
||||||
|
// Text and mentions are placed in a single paragraph (text first, then mentions).
|
||||||
|
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
|
||||||
|
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
|
||||||
|
|
||||||
|
// Strip @{userID} placeholders from text to avoid duplicate mentions
|
||||||
|
// (these placeholders are only for readability in the output format)
|
||||||
|
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
|
||||||
|
// Collapse multiple spaces and trim
|
||||||
|
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
|
||||||
|
strippedText = strings.TrimSpace(strippedText)
|
||||||
|
|
||||||
|
// Add text element if stripped text is not empty
|
||||||
|
if strippedText != "" {
|
||||||
|
text := strippedText
|
||||||
|
elements = append(elements, ContentParagraphElement{
|
||||||
|
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||||
|
TextRun: &ContentTextRun{
|
||||||
|
Text: &text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mention elements
|
||||||
|
for _, mention := range s.Mention {
|
||||||
|
m := mention
|
||||||
|
elements = append(elements, ContentParagraphElement{
|
||||||
|
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||||
|
Mention: &ContentMention{
|
||||||
|
UserID: &m,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ContentBlock{
|
||||||
|
Blocks: []ContentBlockElement{
|
||||||
|
{
|
||||||
|
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||||
|
Paragraph: &ContentParagraph{
|
||||||
|
Elements: elements,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildContentBlock converts text and mentions to a ContentBlock.
|
||||||
|
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
|
||||||
|
func BuildContentBlock(text string, mentions []string) *ContentBlock {
|
||||||
|
return (&SemiPlainContent{
|
||||||
|
Text: text,
|
||||||
|
Mention: mentions,
|
||||||
|
}).ToContentBlock()
|
||||||
|
}
|
||||||
|
|
||||||
// ProgressRateV1 进度率
|
// ProgressRateV1 进度率
|
||||||
type ProgressRateV1 struct {
|
type ProgressRateV1 struct {
|
||||||
Percent *float64 `json:"percent,omitempty"`
|
Percent *float64 `json:"percent,omitempty"`
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
|
|||||||
convey.So(resp, convey.ShouldNotBeNil)
|
convey.So(resp, convey.ShouldNotBeNil)
|
||||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
// Verify removed fields are not present in RespCycle
|
||||||
|
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
|
||||||
|
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
|
||||||
})
|
})
|
||||||
|
|
||||||
convey.Convey("Objective", func() {
|
convey.Convey("Objective", func() {
|
||||||
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
|
|||||||
// boolPtr returns a pointer to the given bool value.
|
// boolPtr returns a pointer to the given bool value.
|
||||||
func boolPtr(v bool) *bool { return &v }
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|
||||||
|
// ========== SemiPlainContent Conversion Tests ==========
|
||||||
|
|
||||||
|
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cb := &ContentBlock{
|
||||||
|
Blocks: []ContentBlockElement{
|
||||||
|
{
|
||||||
|
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||||
|
Paragraph: &ContentParagraph{
|
||||||
|
Elements: []ContentParagraphElement{
|
||||||
|
{
|
||||||
|
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||||
|
TextRun: &ContentTextRun{
|
||||||
|
Text: strPtr("Hello world"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sp := cb.ToSemiPlain()
|
||||||
|
if sp == nil {
|
||||||
|
t.Fatal("expected non-nil SemiPlainContent")
|
||||||
|
}
|
||||||
|
if sp.Text != "Hello world" {
|
||||||
|
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
|
||||||
|
}
|
||||||
|
if len(sp.Mention) != 0 {
|
||||||
|
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
|
||||||
|
}
|
||||||
|
if len(sp.Docs) != 0 {
|
||||||
|
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
|
||||||
|
}
|
||||||
|
if len(sp.Images) != 0 {
|
||||||
|
t.Fatalf("expected 0 images, got %d", len(sp.Images))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cb := &ContentBlock{
|
||||||
|
Blocks: []ContentBlockElement{
|
||||||
|
{
|
||||||
|
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||||
|
Paragraph: &ContentParagraph{
|
||||||
|
Elements: []ContentParagraphElement{
|
||||||
|
{
|
||||||
|
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||||
|
TextRun: &ContentTextRun{
|
||||||
|
Text: strPtr("Hello "),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
|
||||||
|
Mention: &ContentMention{
|
||||||
|
UserID: strPtr("ou_123"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||||
|
TextRun: &ContentTextRun{
|
||||||
|
Text: strPtr(", how are you?"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sp := cb.ToSemiPlain()
|
||||||
|
if sp == nil {
|
||||||
|
t.Fatal("expected non-nil SemiPlainContent")
|
||||||
|
}
|
||||||
|
// Text includes @{userID} placeholder to preserve positional context
|
||||||
|
if sp.Text != "Hello @{ou_123} , how are you?" {
|
||||||
|
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
|
||||||
|
}
|
||||||
|
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
|
||||||
|
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cb := &ContentBlock{
|
||||||
|
Blocks: []ContentBlockElement{
|
||||||
|
{
|
||||||
|
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||||
|
Paragraph: &ContentParagraph{
|
||||||
|
Elements: []ContentParagraphElement{
|
||||||
|
{
|
||||||
|
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||||
|
TextRun: &ContentTextRun{
|
||||||
|
Text: strPtr("Check out this doc: "),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
|
||||||
|
DocsLink: &ContentDocsLink{
|
||||||
|
Title: strPtr("Design Doc"),
|
||||||
|
URL: strPtr("https://example.feishu.cn/docx/xxx"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BlockElementType: BlockElementTypeGallery.Ptr(),
|
||||||
|
Gallery: &ContentGallery{
|
||||||
|
Images: []ContentImageItem{
|
||||||
|
{
|
||||||
|
Src: strPtr("https://example.com/img1.png"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Src: strPtr("https://example.com/img2.png"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sp := cb.ToSemiPlain()
|
||||||
|
if sp == nil {
|
||||||
|
t.Fatal("expected non-nil SemiPlainContent")
|
||||||
|
}
|
||||||
|
if sp.Text != "Check out this doc: " {
|
||||||
|
t.Fatalf("unexpected text: '%s'", sp.Text)
|
||||||
|
}
|
||||||
|
if len(sp.Docs) != 1 {
|
||||||
|
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
|
||||||
|
}
|
||||||
|
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
|
||||||
|
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
|
||||||
|
}
|
||||||
|
if len(sp.Images) != 2 {
|
||||||
|
t.Fatalf("expected 2 images, got %d", len(sp.Images))
|
||||||
|
}
|
||||||
|
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
|
||||||
|
t.Fatalf("unexpected images: %v", sp.Images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var cb *ContentBlock
|
||||||
|
sp := cb.ToSemiPlain()
|
||||||
|
if sp != nil {
|
||||||
|
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sp := &SemiPlainContent{
|
||||||
|
Text: "Hello world",
|
||||||
|
}
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
if len(cb.Blocks) != 1 {
|
||||||
|
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||||
|
}
|
||||||
|
block := cb.Blocks[0]
|
||||||
|
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
|
||||||
|
t.Fatal("expected paragraph block")
|
||||||
|
}
|
||||||
|
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
|
||||||
|
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
|
||||||
|
}
|
||||||
|
elem := block.Paragraph.Elements[0]
|
||||||
|
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
|
||||||
|
t.Fatal("expected textRun element")
|
||||||
|
}
|
||||||
|
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
|
||||||
|
t.Fatalf("unexpected text: %v", elem.TextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sp := &SemiPlainContent{
|
||||||
|
Text: "Please review",
|
||||||
|
Mention: []string{"ou_123", "ou_456"},
|
||||||
|
}
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
if len(cb.Blocks) != 1 {
|
||||||
|
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
|
||||||
|
}
|
||||||
|
elems := cb.Blocks[0].Paragraph.Elements
|
||||||
|
if len(elems) != 3 {
|
||||||
|
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||||
|
}
|
||||||
|
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
|
||||||
|
t.Fatal("unexpected first element")
|
||||||
|
}
|
||||||
|
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
|
||||||
|
t.Fatal("unexpected second element")
|
||||||
|
}
|
||||||
|
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
|
||||||
|
t.Fatal("unexpected third element")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sp := &SemiPlainContent{
|
||||||
|
Text: " ",
|
||||||
|
Mention: []string{"ou_123"},
|
||||||
|
}
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
elems := cb.Blocks[0].Paragraph.Elements
|
||||||
|
// Empty text should be skipped, only mention remains
|
||||||
|
if len(elems) != 1 {
|
||||||
|
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
|
||||||
|
}
|
||||||
|
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||||
|
t.Fatal("expected mention element")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
sp := &SemiPlainContent{
|
||||||
|
Text: "Test",
|
||||||
|
Mention: []string{"ou_123"},
|
||||||
|
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
|
||||||
|
Images: []string{"https://img.png"},
|
||||||
|
}
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
elems := cb.Blocks[0].Paragraph.Elements
|
||||||
|
// Docs and images are ignored in input conversion
|
||||||
|
if len(elems) != 2 {
|
||||||
|
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// Simulate round-trip: output format has @{userID} in text,
|
||||||
|
// input conversion should strip them to avoid duplicate mentions
|
||||||
|
sp := &SemiPlainContent{
|
||||||
|
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
|
||||||
|
Mention: []string{"ou_zhangsan", "ou_lisi"},
|
||||||
|
}
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
elems := cb.Blocks[0].Paragraph.Elements
|
||||||
|
// Should have 3 elements: 1 text (stripped) + 2 mentions
|
||||||
|
if len(elems) != 3 {
|
||||||
|
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
|
||||||
|
}
|
||||||
|
// Text should have placeholders stripped
|
||||||
|
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
|
||||||
|
t.Fatal("expected first element to be textRun")
|
||||||
|
}
|
||||||
|
// Note: space before comma is preserved from the placeholder's trailing space
|
||||||
|
expectedText := "任务一 ,任务二"
|
||||||
|
if *elems[0].TextRun.Text != expectedText {
|
||||||
|
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
|
||||||
|
}
|
||||||
|
// Mentions should be preserved as separate elements
|
||||||
|
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
|
||||||
|
t.Fatal("unexpected second element")
|
||||||
|
}
|
||||||
|
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
|
||||||
|
t.Fatal("unexpected third element")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// Text that is only placeholders should result in no text element
|
||||||
|
sp := &SemiPlainContent{
|
||||||
|
Text: " @{ou_123} @{ou_456} ",
|
||||||
|
Mention: []string{"ou_123", "ou_456"},
|
||||||
|
}
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
elems := cb.Blocks[0].Paragraph.Elements
|
||||||
|
// Should have only 2 mention elements, no text element
|
||||||
|
if len(elems) != 2 {
|
||||||
|
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
|
||||||
|
}
|
||||||
|
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
|
||||||
|
t.Fatal("expected first element to be mention")
|
||||||
|
}
|
||||||
|
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
|
||||||
|
t.Fatal("expected second element to be mention")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var sp *SemiPlainContent
|
||||||
|
cb := sp.ToContentBlock()
|
||||||
|
if cb != nil {
|
||||||
|
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContentBlock_Conversion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
|
||||||
|
if cb == nil {
|
||||||
|
t.Fatal("expected non-nil ContentBlock")
|
||||||
|
}
|
||||||
|
elems := cb.Blocks[0].Paragraph.Elements
|
||||||
|
if len(elems) != 3 {
|
||||||
|
t.Fatalf("expected 3 elements, got %d", len(elems))
|
||||||
|
}
|
||||||
|
if *elems[0].TextRun.Text != "Test text" {
|
||||||
|
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
|
||||||
|
}
|
||||||
|
if *elems[1].Mention.UserID != "ou_123" {
|
||||||
|
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
|
||||||
|
}
|
||||||
|
if *elems[2].Mention.UserID != "ou_456" {
|
||||||
|
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToSimpleMethods(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Test Objective.ToSimple()
|
||||||
|
text := "Objective text"
|
||||||
|
obj := &Objective{
|
||||||
|
ID: "obj-1",
|
||||||
|
Content: BuildContentBlock(text, []string{"ou_123"}),
|
||||||
|
Notes: BuildContentBlock("Note text", nil),
|
||||||
|
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
|
||||||
|
CycleID: "cycle-1",
|
||||||
|
Score: float64Ptr(0.7),
|
||||||
|
Weight: float64Ptr(0.5),
|
||||||
|
Deadline: strPtr("1735776000000"),
|
||||||
|
}
|
||||||
|
simpleObj := obj.ToSimple()
|
||||||
|
if simpleObj == nil {
|
||||||
|
t.Fatal("expected non-nil RespObjectiveSimple")
|
||||||
|
}
|
||||||
|
if simpleObj.ID != "obj-1" {
|
||||||
|
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
|
||||||
|
}
|
||||||
|
// Text includes @{userID} placeholder for positional context
|
||||||
|
expectedContentText := "Objective text @{ou_123} "
|
||||||
|
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
|
||||||
|
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
|
||||||
|
}
|
||||||
|
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
|
||||||
|
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
|
||||||
|
}
|
||||||
|
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
|
||||||
|
t.Fatalf("unexpected score: %v", simpleObj.Score)
|
||||||
|
}
|
||||||
|
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
|
||||||
|
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test KeyResult.ToSimple()
|
||||||
|
kr := &KeyResult{
|
||||||
|
ID: "kr-1",
|
||||||
|
ObjectiveID: "obj-1",
|
||||||
|
Content: BuildContentBlock("KR text", nil),
|
||||||
|
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
|
||||||
|
Score: float64Ptr(0.5),
|
||||||
|
}
|
||||||
|
simpleKR := kr.ToSimple()
|
||||||
|
if simpleKR == nil {
|
||||||
|
t.Fatal("expected non-nil RespKeyResultSimple")
|
||||||
|
}
|
||||||
|
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
|
||||||
|
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ProgressV1.ToSimple()
|
||||||
|
progress := &ProgressV1{
|
||||||
|
ID: "prog-1",
|
||||||
|
ModifyTime: "1735776000000",
|
||||||
|
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
|
||||||
|
}
|
||||||
|
simpleProgress := progress.ToSimple()
|
||||||
|
if simpleProgress == nil {
|
||||||
|
t.Fatal("expected non-nil RespProgressSimple")
|
||||||
|
}
|
||||||
|
// Text includes @{userID} placeholder for positional context
|
||||||
|
expectedProgressText := "Progress text @{ou_mention} "
|
||||||
|
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
|
||||||
|
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
|
||||||
|
}
|
||||||
|
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
|
||||||
|
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Progress.ToSimple() (V2 progress record)
|
||||||
|
progressV2 := &Progress{
|
||||||
|
ID: "prog-v2-1",
|
||||||
|
CreateTime: "1735689600000",
|
||||||
|
UpdateTime: "1735776000000",
|
||||||
|
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
|
||||||
|
ProgressRate: &ProgressRate{
|
||||||
|
ProgressPercent: float64Ptr(80.0),
|
||||||
|
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
simpleProgressV2 := progressV2.ToSimple()
|
||||||
|
if simpleProgressV2 == nil {
|
||||||
|
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
|
||||||
|
}
|
||||||
|
if simpleProgressV2.ID != "prog-v2-1" {
|
||||||
|
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
|
||||||
|
}
|
||||||
|
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
|
||||||
|
t.Fatal("expected non-empty CreateTime for Progress V2")
|
||||||
|
}
|
||||||
|
expectedV2Text := "V2 progress text @{ou_v2_mention} "
|
||||||
|
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
|
||||||
|
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
|
||||||
|
}
|
||||||
|
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
|
||||||
|
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
|
||||||
|
}
|
||||||
|
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
|
||||||
|
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
|
||||||
|
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// listTypePtr returns a pointer to the given ListType value.
|
// listTypePtr returns a pointer to the given ListType value.
|
||||||
func listTypePtr(v ListType) *ListType { return &v }
|
func listTypePtr(v ListType) *ListType { return &v }
|
||||||
|
|||||||
311
shortcuts/okr/okr_patch.go
Normal file
311
shortcuts/okr/okr_patch.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package okr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// patchParams holds the parsed parameters for the patch operation.
|
||||||
|
type patchParams struct {
|
||||||
|
Level string
|
||||||
|
TargetID string
|
||||||
|
Style string
|
||||||
|
Content *ContentBlock
|
||||||
|
Notes *ContentBlock
|
||||||
|
Score *float64
|
||||||
|
Deadline *string
|
||||||
|
UserIDType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
|
||||||
|
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
|
||||||
|
p := &patchParams{
|
||||||
|
Level: runtime.Str("level"),
|
||||||
|
TargetID: runtime.Str("target-id"),
|
||||||
|
Style: runtime.Str("style"),
|
||||||
|
UserIDType: runtime.Str("user-id-type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
hasField := false
|
||||||
|
|
||||||
|
// Parse content if provided
|
||||||
|
if contentStr := runtime.Str("content"); contentStr != "" {
|
||||||
|
hasField = true
|
||||||
|
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.Style == "simple" {
|
||||||
|
var sp SemiPlainContent
|
||||||
|
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sp.Text) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||||
|
}
|
||||||
|
for i, m := range sp.Mention {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||||
|
}
|
||||||
|
p.Content = sp.ToContentBlock()
|
||||||
|
} else {
|
||||||
|
var cb ContentBlock
|
||||||
|
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
p.Content = &cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse notes if provided (only for objective)
|
||||||
|
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||||
|
hasField = true
|
||||||
|
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.Level != "objective" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
|
||||||
|
}
|
||||||
|
if p.Style == "simple" {
|
||||||
|
var sp SemiPlainContent
|
||||||
|
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sp.Text) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
|
||||||
|
}
|
||||||
|
for i, m := range sp.Mention {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
|
||||||
|
}
|
||||||
|
p.Notes = sp.ToContentBlock()
|
||||||
|
} else {
|
||||||
|
var cb ContentBlock
|
||||||
|
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
|
||||||
|
}
|
||||||
|
p.Notes = &cb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse score if provided
|
||||||
|
if scoreStr := runtime.Str("score"); scoreStr != "" {
|
||||||
|
hasField = true
|
||||||
|
score, err := strconv.ParseFloat(scoreStr, 64)
|
||||||
|
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
|
||||||
|
}
|
||||||
|
if score < 0 || score > 1 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
|
||||||
|
}
|
||||||
|
// Check for exactly one decimal place
|
||||||
|
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
|
||||||
|
parts := strings.Split(scoreStrTrimmed, ".")
|
||||||
|
if len(parts) == 2 && len(parts[1]) > 1 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
|
||||||
|
}
|
||||||
|
// Validation ensures at most one decimal place, so score is already correctly formatted
|
||||||
|
p.Score = &score
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse deadline if provided
|
||||||
|
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
|
||||||
|
hasField = true
|
||||||
|
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
|
||||||
|
}
|
||||||
|
if deadlineMs <= 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
|
||||||
|
}
|
||||||
|
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
|
||||||
|
// Anything less than 1e12 is likely seconds or a wrong unit
|
||||||
|
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
|
||||||
|
}
|
||||||
|
p.Deadline = &deadlineStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one field must be provided
|
||||||
|
if !hasField {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OKRPatch patches an objective or key result.
|
||||||
|
var OKRPatch = common.Shortcut{
|
||||||
|
Service: "okr",
|
||||||
|
Command: "+patch",
|
||||||
|
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
|
||||||
|
Risk: "write",
|
||||||
|
Scopes: []string{"okr:okr.content:writeonly"},
|
||||||
|
AuthTypes: []string{"user", "bot"},
|
||||||
|
HasFormat: true,
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||||
|
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||||
|
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||||
|
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||||
|
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||||
|
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
|
||||||
|
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
|
||||||
|
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||||
|
},
|
||||||
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
level := runtime.Str("level")
|
||||||
|
if level != "objective" && level != "key-result" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID := runtime.Str("target-id")
|
||||||
|
if targetID == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
|
||||||
|
}
|
||||||
|
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
style := runtime.Str("style")
|
||||||
|
if style != "simple" && style != "richtext" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||||
|
}
|
||||||
|
|
||||||
|
idType := runtime.Str("user-id-type")
|
||||||
|
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate content/notes/score/deadline validation to parsePatchParams
|
||||||
|
if _, err := parsePatchParams(runtime); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
|
p, err := parsePatchParams(runtime)
|
||||||
|
if err != nil {
|
||||||
|
return common.NewDryRunAPI().
|
||||||
|
PATCH("").
|
||||||
|
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
if p.Content != nil {
|
||||||
|
body["content"] = p.Content
|
||||||
|
}
|
||||||
|
if p.Notes != nil {
|
||||||
|
body["notes"] = p.Notes
|
||||||
|
}
|
||||||
|
if p.Score != nil {
|
||||||
|
body["score"] = *p.Score
|
||||||
|
}
|
||||||
|
if p.Deadline != nil {
|
||||||
|
body["deadline"] = *p.Deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"user_id_type": p.UserIDType,
|
||||||
|
}
|
||||||
|
|
||||||
|
api := common.NewDryRunAPI()
|
||||||
|
if p.Level == "objective" {
|
||||||
|
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
|
||||||
|
Set("objective_id", p.TargetID)
|
||||||
|
} else {
|
||||||
|
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
|
||||||
|
Set("key_result_id", p.TargetID)
|
||||||
|
}
|
||||||
|
return api.Params(params).Body(body).
|
||||||
|
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
|
||||||
|
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
|
||||||
|
},
|
||||||
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
|
p, err := parsePatchParams(runtime)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
if p.Content != nil {
|
||||||
|
body["content"] = p.Content
|
||||||
|
}
|
||||||
|
if p.Notes != nil {
|
||||||
|
body["notes"] = p.Notes
|
||||||
|
}
|
||||||
|
if p.Score != nil {
|
||||||
|
body["score"] = *p.Score
|
||||||
|
}
|
||||||
|
if p.Deadline != nil {
|
||||||
|
body["deadline"] = *p.Deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParams := map[string]interface{}{
|
||||||
|
"user_id_type": p.UserIDType,
|
||||||
|
}
|
||||||
|
|
||||||
|
var path string
|
||||||
|
if p.Level == "objective" {
|
||||||
|
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
|
||||||
|
} else {
|
||||||
|
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
|
||||||
|
if err != nil {
|
||||||
|
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"level": p.Level,
|
||||||
|
"target_id": p.TargetID,
|
||||||
|
"patched": map[string]bool{
|
||||||
|
"content": p.Content != nil,
|
||||||
|
"notes": p.Notes != nil,
|
||||||
|
"score": p.Score != nil,
|
||||||
|
"deadline": p.Deadline != nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
|
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
|
||||||
|
if p.Content != nil {
|
||||||
|
fmt.Fprintf(w, " - content: updated\n")
|
||||||
|
}
|
||||||
|
if p.Notes != nil {
|
||||||
|
fmt.Fprintf(w, " - notes: updated\n")
|
||||||
|
}
|
||||||
|
if p.Score != nil {
|
||||||
|
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
|
||||||
|
}
|
||||||
|
if p.Deadline != nil {
|
||||||
|
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
1350
shortcuts/okr/okr_patch_test.go
Normal file
1350
shortcuts/okr/okr_patch_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
|
|||||||
|
|
||||||
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||||
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
|
||||||
|
style := runtime.Str("style")
|
||||||
content := runtime.Str("content")
|
content := runtime.Str("content")
|
||||||
var cb ContentBlock
|
var contentV1 *ContentBlockV1
|
||||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
if style == "simple" {
|
||||||
|
var sp SemiPlainContent
|
||||||
|
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sp.Text) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||||
|
}
|
||||||
|
// Validate mention IDs are non-empty
|
||||||
|
for i, m := range sp.Mention {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||||
|
}
|
||||||
|
// Build ContentBlock from semi-plain content (text + mentions)
|
||||||
|
contentV1 = sp.ToContentBlock().ToV1()
|
||||||
|
} else {
|
||||||
|
// richtext mode
|
||||||
|
var cb ContentBlock
|
||||||
|
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
contentV1 = cb.ToV1()
|
||||||
}
|
}
|
||||||
contentV1 := cb.ToV1()
|
|
||||||
|
|
||||||
targetType := runtime.Str("target-type")
|
targetType := runtime.Str("target-type")
|
||||||
targetTypeVal := targetTypeAllowed[targetType]
|
targetTypeVal := targetTypeAllowed[targetType]
|
||||||
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||||
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
|
||||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||||
{Name: "progress-percent", Desc: "progress percentage"},
|
{Name: "progress-percent", Desc: "progress percentage"},
|
||||||
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
|
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
|
||||||
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
|
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
|
||||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||||
|
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
content := runtime.Str("content")
|
content := runtime.Str("content")
|
||||||
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Validate content is valid JSON and can be parsed as ContentBlock
|
|
||||||
var cb ContentBlock
|
style := runtime.Str("style")
|
||||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
if style != "simple" && style != "richtext" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate content based on style
|
||||||
|
if style == "simple" {
|
||||||
|
var sp SemiPlainContent
|
||||||
|
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sp.Text) == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||||
|
}
|
||||||
|
for i, m := range sp.Mention {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If user provided docs or images in simple mode, warn that they are ignored
|
||||||
|
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// richtext mode
|
||||||
|
var cb ContentBlock
|
||||||
|
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
targetID := runtime.Str("target-id")
|
targetID := runtime.Str("target-id")
|
||||||
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := record.ToResp()
|
style := runtime.Str("style")
|
||||||
result := map[string]interface{}{
|
var result map[string]interface{}
|
||||||
"progress": resp,
|
if style == "simple" {
|
||||||
}
|
resp := record.ToSimple()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID)
|
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if resp.Content != nil {
|
||||||
|
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp := record.ToResp()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
}
|
}
|
||||||
if resp.Content != nil {
|
|
||||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
}
|
fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
})
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
|
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if resp.Content != nil {
|
||||||
|
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||||
|
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
|
||||||
|
|
||||||
// --- Validate tests ---
|
// --- Validate tests ---
|
||||||
|
|
||||||
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", "not-json",
|
"--content", "not-json",
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "abc",
|
"--target-id", "abc",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "invalid",
|
"--target-type", "invalid",
|
||||||
})
|
})
|
||||||
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--user-id-type", "invalid",
|
"--user-id-type", "invalid",
|
||||||
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-percent", "999999999999",
|
"--progress-percent", "999999999999",
|
||||||
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-percent", "abc",
|
"--progress-percent", "abc",
|
||||||
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-status", "invalid_status",
|
"--progress-status", "invalid_status",
|
||||||
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "123",
|
"--target-id", "123",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
"--progress-percent", "75",
|
"--progress-percent", "75",
|
||||||
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "456",
|
"--target-id", "456",
|
||||||
"--target-type", "key_result",
|
"--target-type", "key_result",
|
||||||
})
|
})
|
||||||
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
|||||||
err := runProgressCreateShortcut(t, f, stdout, []string{
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
"+progress-create",
|
"+progress-create",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--target-id", "789",
|
"--target-id", "789",
|
||||||
"--target-type", "objective",
|
"--target-type", "objective",
|
||||||
})
|
})
|
||||||
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
|
|||||||
t.Fatal("expected error for API failure")
|
t.Fatal("expected error for API failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Simple mode tests ---
|
||||||
|
|
||||||
|
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/okr/v1/progress_records/",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"progress_id": "300",
|
||||||
|
"modify_time": "1735776000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Use default style (simple) without specifying --style
|
||||||
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-create",
|
||||||
|
"--content", validSemiPlainJSON,
|
||||||
|
"--target-id", "123",
|
||||||
|
"--target-type", "objective",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
data := decodeEnvelope(t, stdout)
|
||||||
|
pr, _ := data["progress"].(map[string]interface{})
|
||||||
|
if pr == nil {
|
||||||
|
t.Fatal("expected progress in output")
|
||||||
|
}
|
||||||
|
if pr["progress_id"] != "300" {
|
||||||
|
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/okr/v1/progress_records/",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"progress_id": "400",
|
||||||
|
"modify_time": "1735776000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Explicitly specify --style simple with mentions
|
||||||
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-create",
|
||||||
|
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
|
||||||
|
"--style", "simple",
|
||||||
|
"--target-id", "456",
|
||||||
|
"--target-type", "key_result",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
data := decodeEnvelope(t, stdout)
|
||||||
|
pr, _ := data["progress"].(map[string]interface{})
|
||||||
|
if pr == nil {
|
||||||
|
t.Fatal("expected progress in output")
|
||||||
|
}
|
||||||
|
if pr["progress_id"] != "400" {
|
||||||
|
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||||
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-create",
|
||||||
|
"--content", `{"text":"missing closing brace`,
|
||||||
|
"--target-id", "123",
|
||||||
|
"--target-type", "objective",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid semi-plain JSON")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got: %v", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation {
|
||||||
|
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "--content" {
|
||||||
|
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||||
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-create",
|
||||||
|
"--content", `{"text":" ","mention":[]}`,
|
||||||
|
"--target-id", "123",
|
||||||
|
"--target-type", "objective",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty text in simple mode")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got: %v", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation {
|
||||||
|
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "--content" {
|
||||||
|
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||||
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-create",
|
||||||
|
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
|
||||||
|
"--target-id", "123",
|
||||||
|
"--target-type", "objective",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for docs in simple mode")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got: %v", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation {
|
||||||
|
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "--content" {
|
||||||
|
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
|
||||||
|
err := runProgressCreateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-create",
|
||||||
|
"--content", validSemiPlainJSON,
|
||||||
|
"--target-id", "123",
|
||||||
|
"--target-type", "objective",
|
||||||
|
"--dry-run",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
|
||||||
|
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "POST") {
|
||||||
|
t.Fatalf("dry-run output should contain POST method, got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
|||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||||
|
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
progressID := runtime.Str("progress-id")
|
progressID := runtime.Str("progress-id")
|
||||||
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
|
|||||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||||
}
|
}
|
||||||
|
style := runtime.Str("style")
|
||||||
|
if style != "simple" && style != "richtext" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
@@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{
|
|||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
progressID := runtime.Str("progress-id")
|
progressID := runtime.Str("progress-id")
|
||||||
userIDType := runtime.Str("user-id-type")
|
userIDType := runtime.Str("user-id-type")
|
||||||
|
style := runtime.Str("style")
|
||||||
|
|
||||||
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
queryParams := map[string]interface{}{"user_id_type": userIDType}
|
||||||
|
|
||||||
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := record.ToResp()
|
var result map[string]interface{}
|
||||||
result := map[string]interface{}{
|
if style == "simple" {
|
||||||
"progress": resp,
|
resp := record.ToSimple()
|
||||||
}
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Progress [%s]\n", resp.ID)
|
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if resp.Content != nil {
|
||||||
|
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||||
|
if len(resp.Content.Mention) > 0 {
|
||||||
|
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp := record.ToResp()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
}
|
}
|
||||||
if resp.Content != nil {
|
|
||||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
}
|
fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
})
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
|
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if resp.Content != nil {
|
||||||
|
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
"github.com/larksuite/cli/shortcuts/common"
|
||||||
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
|
|||||||
|
|
||||||
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
|
||||||
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
|
||||||
|
style := runtime.Str("style")
|
||||||
content := runtime.Str("content")
|
content := runtime.Str("content")
|
||||||
var cb ContentBlock
|
var contentV1 *ContentBlockV1
|
||||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
|
||||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
if style == "simple" {
|
||||||
|
var sp SemiPlainContent
|
||||||
|
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sp.Text) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||||
|
}
|
||||||
|
for i, m := range sp.Mention {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||||
|
}
|
||||||
|
contentV1 = sp.ToContentBlock().ToV1()
|
||||||
|
} else {
|
||||||
|
// richtext mode
|
||||||
|
var cb ContentBlock
|
||||||
|
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||||
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
contentV1 = cb.ToV1()
|
||||||
}
|
}
|
||||||
contentV1 := cb.ToV1()
|
|
||||||
|
|
||||||
var progressRate *ProgressRateV1
|
var progressRate *ProgressRateV1
|
||||||
if v := runtime.Str("progress-percent"); v != "" {
|
if v := runtime.Str("progress-percent"); v != "" {
|
||||||
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
|||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
{Name: "progress-id", Desc: "progress ID (int64)", Required: true},
|
||||||
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}},
|
{Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||||
{Name: "progress-percent", Desc: "progress percentage"},
|
{Name: "progress-percent", Desc: "progress percentage"},
|
||||||
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
|
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
|
||||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||||
|
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||||
},
|
},
|
||||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
progressID := runtime.Str("progress-id")
|
progressID := runtime.Str("progress-id")
|
||||||
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
|||||||
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var cb ContentBlock
|
|
||||||
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
style := runtime.Str("style")
|
||||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
if style != "simple" && style != "richtext" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate content based on style
|
||||||
|
if style == "simple" {
|
||||||
|
var sp SemiPlainContent
|
||||||
|
if err := json.Unmarshal([]byte(content), &sp); err != nil {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sp.Text) == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
|
||||||
|
}
|
||||||
|
for i, m := range sp.Mention {
|
||||||
|
if strings.TrimSpace(m) == "" {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// richtext mode
|
||||||
|
var cb ContentBlock
|
||||||
|
if err := json.Unmarshal([]byte(content), &cb); err != nil {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if v := runtime.Str("progress-percent"); v != "" {
|
if v := runtime.Str("progress-percent"); v != "" {
|
||||||
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := record.ToResp()
|
style := runtime.Str("style")
|
||||||
result := map[string]interface{}{
|
var result map[string]interface{}
|
||||||
"progress": resp,
|
if style == "simple" {
|
||||||
}
|
resp := record.ToSimple()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
|
}
|
||||||
|
|
||||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID)
|
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if resp.Content != nil {
|
||||||
|
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resp := record.ToResp()
|
||||||
|
result = map[string]interface{}{
|
||||||
|
"progress": resp,
|
||||||
|
"style": style,
|
||||||
}
|
}
|
||||||
if resp.Content != nil {
|
|
||||||
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||||
}
|
fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
|
||||||
})
|
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
|
||||||
|
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
|
||||||
|
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
|
||||||
|
}
|
||||||
|
if resp.Content != nil {
|
||||||
|
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ package okr
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
|
|||||||
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing --progress-id")
|
t.Fatal("expected error for missing --progress-id")
|
||||||
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "abc",
|
"--progress-id", "abc",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid --progress-id")
|
t.Fatal("expected error for invalid --progress-id")
|
||||||
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", "not-json",
|
"--content", "not-json",
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for invalid --content JSON")
|
t.Fatal("expected error for invalid --content JSON")
|
||||||
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--user-id-type", "invalid",
|
"--user-id-type", "invalid",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--progress-percent", "-999999999999",
|
"--progress-percent", "-999999999999",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--progress-status", "invalid_status",
|
"--progress-status", "invalid_status",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "123",
|
"--progress-id", "123",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "456",
|
"--progress-id", "456",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "456",
|
"--progress-id", "456",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
"--progress-percent", "50",
|
"--progress-percent", "50",
|
||||||
"--progress-status", "overdue",
|
"--progress-status", "overdue",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "789",
|
"--progress-id", "789",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
|
|||||||
"+progress-update",
|
"+progress-update",
|
||||||
"--progress-id", "999",
|
"--progress-id", "999",
|
||||||
"--content", validContentBlockJSON,
|
"--content", validContentBlockJSON,
|
||||||
|
"--style", "richtext",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for API failure")
|
t.Fatal("expected error for API failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Simple mode tests ---
|
||||||
|
|
||||||
|
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "PUT",
|
||||||
|
URL: "/open-apis/okr/v1/progress_records/500",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"progress_id": "500",
|
||||||
|
"modify_time": "1735776000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Use default style (simple) without specifying --style
|
||||||
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-update",
|
||||||
|
"--progress-id", "500",
|
||||||
|
"--content", validSemiPlainJSON,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
data := decodeEnvelope(t, stdout)
|
||||||
|
pr, _ := data["progress"].(map[string]interface{})
|
||||||
|
if pr == nil {
|
||||||
|
t.Fatal("expected progress in output")
|
||||||
|
}
|
||||||
|
if pr["progress_id"] != "500" {
|
||||||
|
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "PUT",
|
||||||
|
URL: "/open-apis/okr/v1/progress_records/600",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"progress_id": "600",
|
||||||
|
"modify_time": "1735776000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Explicitly specify --style simple with mentions and progress rate
|
||||||
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-update",
|
||||||
|
"--progress-id", "600",
|
||||||
|
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
|
||||||
|
"--style", "simple",
|
||||||
|
"--progress-percent", "80",
|
||||||
|
"--progress-status", "normal",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
data := decodeEnvelope(t, stdout)
|
||||||
|
pr, _ := data["progress"].(map[string]interface{})
|
||||||
|
if pr == nil {
|
||||||
|
t.Fatal("expected progress in output")
|
||||||
|
}
|
||||||
|
if pr["progress_id"] != "600" {
|
||||||
|
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||||
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-update",
|
||||||
|
"--progress-id", "123",
|
||||||
|
"--content", `{"text":"invalid json`,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid semi-plain JSON")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got: %v", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation {
|
||||||
|
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "--content" {
|
||||||
|
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||||
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-update",
|
||||||
|
"--progress-id", "123",
|
||||||
|
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty mention in simple mode")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got: %v", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation {
|
||||||
|
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "--content" {
|
||||||
|
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||||
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-update",
|
||||||
|
"--progress-id", "123",
|
||||||
|
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for images in simple mode")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got: %v", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryValidation {
|
||||||
|
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||||
|
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
|
||||||
|
}
|
||||||
|
var validationErr *errs.ValidationError
|
||||||
|
if !errors.As(err, &validationErr) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got: %T", err)
|
||||||
|
}
|
||||||
|
if validationErr.Param != "--content" {
|
||||||
|
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
|
||||||
|
err := runProgressUpdateShortcut(t, f, stdout, []string{
|
||||||
|
"+progress-update",
|
||||||
|
"--progress-id", "700",
|
||||||
|
"--content", validSemiPlainJSON,
|
||||||
|
"--dry-run",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
output := stdout.String()
|
||||||
|
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
|
||||||
|
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "PUT") {
|
||||||
|
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
|
|||||||
OKRReorder,
|
OKRReorder,
|
||||||
OKRWeight,
|
OKRWeight,
|
||||||
OKRIndicatorUpdate,
|
OKRIndicatorUpdate,
|
||||||
|
OKRPatch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||||
|
program := &cobra.Command{Use: "root"}
|
||||||
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
|
|
||||||
|
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||||
|
cmd, _, err := program.Find([]string{"docs", name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||||
|
}
|
||||||
|
if cmd == nil || cmd.Name() != name {
|
||||||
|
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||||
|
}
|
||||||
|
if cmd.Flags().Lookup("api-version") != nil {
|
||||||
|
t.Fatalf("docs %s should not expose --api-version", name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||||
|
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||||
program := &cobra.Command{Use: "root"}
|
program := &cobra.Command{Use: "root"}
|
||||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
|||||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||||
}
|
}
|
||||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") {
|
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||||
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||||
}
|
}
|
||||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user