Compare commits

...
Author SHA1 Message Date
dxb 9d65cbf500 feat(doc): add verified insertion before blocks 2026-08-26 09:53:11 +08:00
dxb b5b07e58af test(app): cover nested signal interruption 2026-08-26 09:53:11 +08:00
dxb 66ffa99fda fix(doc): preserve import recovery semantics 2026-08-26 09:53:11 +08:00
dxb e6afbabad9 test(doc): select platform coverage cases 2026-08-26 09:53:11 +08:00
dxb c7c043f8aa test(doc): cover interrupted import recovery 2026-08-26 09:53:11 +08:00
dxb c8f4719c72 fix(doc): preserve interrupted import recovery 2026-08-26 09:53:11 +08:00
dxb 5677dec657 fix(doc): verify recovered import placement 2026-08-26 09:53:11 +08:00
dxb 81b4e3e81b test(doc): cover shortcut edge paths 2026-08-26 09:53:11 +08:00
dxb 9e1c511523 fix(doc): harden shortcut reliability 2026-08-26 09:53:11 +08:00
dingtalk-dws-reviewer-router[bot] 76618a6b8c Merge pull request #1115
Merged by the dedicated Reviewer Router GitHub App for PR #1115.
2026-08-26 00:59:56 +00:00
liyuan333 7ab86b82f4 Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-26 08:40:38 +08:00
dingtalk-dws-reviewer-router[bot] 5c1bef743a Merge pull request #1125
Merged by the dedicated Reviewer Router GitHub App for PR #1125.
2026-08-26 00:29:24 +00:00
liyuan333 1260c188e1 Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-26 08:19:50 +08:00
liyuan f9a6981232 Merge remote-tracking branch 'upstream/main' into HEAD 2026-08-26 08:14:23 +08:00
dingtalk-dws-reviewer-router[bot] 2b4187aff8 Merge pull request #1145
Merged by the dedicated Reviewer Router GitHub App for PR #1145.
2026-08-25 16:34:48 +00:00
liyuan333 7a9bac0a05 Merge branch 'main' into fix/report-entry-submit-require-recipient 2026-08-25 23:59:48 +08:00
liyuan333 a316222584 Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-25 23:55:07 +08:00
赤川 20ee2cc4ee Merge branch 'main' into codex/cancel-stale-pr-ci 2026-08-25 23:52:05 +08:00
liyuan a584c18dc2 test: align expected download URL boundary error with new message
7f353b73 reworded ValidateDownloadURL's rejection from "下载地址必须是
受信任域名上的 HTTPS URL" to "下载地址必须是合法的 HTTPS URL" when
the IP-literal refusal was removed, but missed that the string is a
test contract: paramAliasExpectedCaptureBoundaryError treats the URL
validation stop as the expected capture boundary for drive +download
and +version-download fixture runs. The stale assertion failed those
subtests and the alias-count invariant (58 active, want 64), which
also failed the Coverage jobs running the same tests.

- update the boundary matcher to the new message
2026-08-25 23:05:30 +08:00
dingtalk-dws-reviewer-router[bot] 1de179cbdf Merge pull request #1093
Merged by the dedicated Reviewer Router GitHub App for PR #1093.
2026-08-25 14:55:32 +00:00
chichuan ff6cf48df6 ci: cancel stale pull request revisions 2026-08-25 22:24:18 +08:00
liyuan333 879054000d Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-25 22:21:45 +08:00
liyuan 4f9cb2ac9f Merge branch 'fix/report-entry-submit-require-recipient' of github.com:liyuan333/dingtalk-workspace-cli into fix/report-entry-submit-require-recipient 2026-08-25 22:10:11 +08:00
liyuan f50f086271 Merge remote-tracking branch 'upstream/main' into fix/report-entry-submit-require-recipient
# Conflicts:
#	scripts/policy/interface-migrations/approved-flag-migrations-v1.json
2026-08-25 22:07:51 +08:00
赤川 0fe9dd8ba0 Merge branch 'main' into fix/markdown-split 2026-08-25 21:46:42 +08:00
赤川 6449ad33c1 Merge pull request #1144 from DingTalk-Real-AI/codex/fix-1093-fork-router-slug
fix(ci): admit fork PRs without repository variables
2026-08-25 21:45:35 +08:00
chichuan c77046531e fix(ci): admit fork PRs without repository variables 2026-08-25 21:43:27 +08:00
赤川 797766ebf7 Merge pull request #1124 from liyuan333/chore/approve-report-recipient-requiredness
chore: approve report to-user-ids requiredness migrations (#85724185)
2026-08-25 21:30:01 +08:00
liyuan 7f353b73a5 fix(security): drop IP-literal refusal from download URL validation
Confirmed with the product team that the official GUI client applies no
client-side SSRF interception to downloads, so the IP-literal refusal
was the last remaining client-side interception beyond transport
hygiene. Dedicated deployments make every host dimension (domain,
port, network location) unenumerable, and an IP-literal host is just
another network location.

- ValidateDownloadURL accepts any HTTPS host, IP literals included;
  HTTPS scheme, userinfo refusal, and per-hop redirect re-validation
  stay as the transport baseline
- this retires the third and last interception layer after the host
  allowlist removal and the dial-time public-IP refusal (be52ca5d)
- upload targets stay unaffected: trustedUploadHost keeps rejecting
  non-DingTalk/OSS hosts, so local file bytes cannot be PUT to an IP
- regressions: IP-literal URLs pass validation on the shared and chat
  download paths; userinfo and plain-HTTP URLs stay rejected
2026-08-25 21:07:18 +08:00
liyuan be52ca5d2a fix(security): remove dial-time public-IP refusal from download client
Customer round-2 testing on the dedicated deployment (Jingbo) found the
storage domain resolving to a customer-intranet address (10.254.87.52),
which the dial-time public-IP policy refused: dedicated storage can be
deployed inside the customer network, so its resolved network location
is as unenumerable as its domain and port.

- delete the public-IP policy, the IANA special-purpose denylist, and
  the NAT64 embedded-IPv4 re-validation introduced in 617b780a;
  downloads now dial the service-issued host directly, still ignoring
  environment proxies
- align with the official GUI client, which applies no client-side
  SSRF interception to downloads: no command accepts a user-supplied
  download URL, TLS hostname verification pins the connection to the
  requested domain, redirects are re-validated per hop, and credential
  headers are stripped once a redirect leaves the original origin
- uploads keep the static DingTalk/OSS default-port trust boundary
- simplify SetSecureDownloadDialTargetForTest to a single dial seam
2026-08-25 20:40:47 +08:00
赤川 d772570f4a Merge branch 'main' into chore/approve-report-recipient-requiredness 2026-08-25 20:27:06 +08:00
赤川 7e1d595036 Merge branch 'main' into fix/markdown-split 2026-08-25 20:22:05 +08:00
dingtalk-dws-reviewer-router[bot] 47ef4274cb Merge pull request #1135
Merged by the dedicated Reviewer Router GitHub App for PR #1135.
2026-08-25 12:20:55 +00:00
liyuan 6134d413f5 fix(security): restore default-port-only HTTPS rule for upload URLs
Review finding on 1ba6bec8: relaxing ValidateDownloadURL to accept
non-default HTTPS ports also widened upload targets, because the upload
validator reuses it and only re-imposed the host trust set.

- validateUploadURL now also rejects non-default ports, making the
  upload trust boundary identical to the pre-removal policy (trusted
  DingTalk/OSS hosts on the default port)
- DingTalk/OSS upload endpoints always serve HTTPS on 443, so unlike
  dedicated-deployment downloads there is no legitimate non-default
  port scenario for uploads
- regressions: trusted-host:8443 upload targets are rejected for both
  public-cloud and dedicated hosts
2026-08-25 20:05:06 +08:00
赤川 529d1f9682 Merge branch 'main' into codex/param-hallucination-remaining-products 2026-08-25 19:54:16 +08:00
赤川 8ed46b2da2 Merge pull request #1142
Break-glass governance rollout by haofeng0705 after exact-head approval and nine source-bound required checks.
2026-08-25 19:53:07 +08:00
liyuan 1ba6bec8c1 fix(security): accept non-default HTTPS ports for download URLs
Customer testing on a dedicated deployment found real download URLs served
on a non-default HTTPS port (e.g. 8443) by the dedicated storage domain,
which the inherited default-port-only rule rejected.

- drop the 443-only restriction from ValidateDownloadURL; HTTPS scheme,
  domain-only hosts (no IP literals), and no-userinfo rules stay
- the port is not a trust signal: SSRF protection lives at dial time in
  the port-agnostic public-IP policy
- redirect hygiene unchanged: a port change is a cross-origin redirect
  and still strips service credential headers (new regression guard)
- dedicated-deployment regression: same host on a non-default port is
  accepted by URL validation and downloads successfully
2026-08-25 19:25:20 +08:00
chichuan 4a4062d09e fix(ci): bind required checks to GitHub Actions 2026-08-25 19:20:57 +08:00
hyz 0e09d23223 Merge branch 'main' into codex/param-hallucination-remaining-products 2026-08-25 18:56:22 +08:00
chichuan 4815fcc7fc fix(ci): merge App-owned PRs synchronously 2026-08-25 18:13:38 +08:00
liyuan333 15a7b0a0a7 Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-25 17:55:54 +08:00
赤川 27e7f1e1f2 Merge pull request #1141
Break-glass semantic rollback of PR #1101, authorized by repository maintainer.
2026-08-25 17:53:28 +08:00
CHHH e9ee516439 Merge branch 'main' into fix/markdown-split 2026-08-25 17:25:47 +08:00
liyuan 617b780a76 fix(security): block IPv4-embedded IPv6 transition ranges in publicIP
- re-validate NAT64 well-known prefix answers (64:ff9b::/96) against the
  embedded IPv4 address: DNS64-synthesized answers for public IPv4-only
  hosts keep working while embedded loopback/private/special addresses
  are refused before dialing
- refuse NAT64 local-use (64:ff9b:1::/48) outright: the IPv4 embedding
  is deployment-specific and cannot be extracted reliably
- refuse Teredo (2001::/32) outright as part of the transition-mechanism
  audit; 6to4 (2002::/16) was already refused and IPv4-mapped addresses
  are normalized via Unmap before checks
- add dial-layer regression: a hostile AAAA answer embedding 127.0.0.1
  fails before any dial attempt
2026-08-25 16:56:20 +08:00
chichuan 09b0a59949 revert: remove edu and college vendor extensions 2026-08-25 16:55:40 +08:00
liyuan333 aa74779aa6 Merge branch 'main' into chore/approve-report-recipient-requiredness 2026-08-25 16:40:01 +08:00
liyuan333 130b57de4d Merge branch 'main' into fix/report-entry-submit-require-recipient 2026-08-25 16:27:20 +08:00
hyz 5a9328ac44 Merge branch 'main' into codex/param-hallucination-remaining-products 2026-08-25 16:21:39 +08:00
赤川 0dc21e8c36 Merge pull request #1077 from DingTalk-Real-AI/codex/fix-ci-baseline-cache-governance
ci: harden coverage baseline governance
2026-08-25 16:12:50 +08:00
liyuan 4423af1af2 fix(security): keep upload host trust and harden non-public IP policy
- restore the pre-existing DingTalk/OSS trusted host requirement for
  upload target URLs via a dedicated upload validator
- extend the dial-time non-public IP denylist with IANA special-purpose
  ranges (0.0.0.0/8, 192.88.99.0/24, 100::/64, 2002::/16, 3fff::/20,
  5f00::/16)
- document that download credential headers follow the service-issued
  URL as-is on the first hop (same authenticated response issues both);
  cross-host redirects keep stripping them
2026-08-25 15:38:49 +08:00
chichuan b45eba0f5c fix(ci): tolerate merged PR base advancement
Bind coverage repair to the stable PR head and merge identity plus protected-main containment without treating the live base SHA projection as permanent identity.

Add semantic regression coverage for base advancement and update the governance documentation.
2026-08-25 14:06:09 +08:00
chichuan c629e1e3eb fix(ci): handle read-only merge defaults projection
GitHub omits merge-related repository settings from tokens without Contents write. Accept only the exact dual omission in read-only admission, and require the dedicated App to observe the reviewed values before any auto-merge mutation.
2026-08-25 12:34:32 +08:00
克谨 0df2d6d630 feat(cli): harden remaining product parameter aliases 2026-08-25 12:33:16 +08:00
chichuan aeb1b2ced2 fix(ci): cross-check strict ruleset via GraphQL 2026-08-25 11:39:33 +08:00
chichuan e3124ccea1 fix(ci): accept strict ruleset read projection 2026-08-25 11:11:21 +08:00
CHHH 3030faf73d Merge branch 'main' into fix/markdown-split 2026-08-25 11:10:03 +08:00
chichuan 5605821a70 Merge remote-tracking branch 'origin/main' into codex/fix-pr-1077-router-governance 2026-08-25 10:54:12 +08:00
liyuan333 9afe9c1436 Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-25 10:23:24 +08:00
github-actions[bot] 3fd0d97a26 Merge pull request #1110 from DingTalk-Real-AI/codex/fix-command-typo-guidance
fix(cli): add bounded command typo guidance
2026-08-25 02:11:21 +00:00
赤川 71556a19d9 Merge branch 'main' into codex/fix-command-typo-guidance 2026-08-25 09:50:28 +08:00
github-actions[bot] 69f1337316 Merge pull request #1109 from DingTalk-Real-AI/codex/dev-devapp-agoal-shortcuts
feat(shortcut): harden DevApp and Agoal task surfaces
2026-08-24 22:55:59 +00:00
Dennis 01476727e9 feat(shortcut): harden DevApp and Agoal task surfaces 2026-08-25 02:36:18 +08:00
chichuan 61ffc988c5 test: cover command resolution edge cases 2026-08-25 01:33:52 +08:00
chichuan 156be4cee7 fix: preserve guarded flag values during command resolution 2026-08-25 00:50:05 +08:00
chichuan 2a42e282c7 fix: align typo guidance with command framework 2026-08-25 00:50:03 +08:00
chichuan f5378d2f28 test: cover typo guidance CI edges 2026-08-25 00:50:01 +08:00
chichuan ea4a463956 fix: add bounded command typo guidance 2026-08-25 00:49:59 +08:00
github-actions[bot] 8d34acbb38 Merge pull request #1130 from DingTalk-Real-AI/codex/fix-todo-upstream-internal-error
fix(todo): classify upstream failures and validate inputs
2026-08-25 00:44:39 +08:00
克谨 9401f9921a merge: sync latest main 2026-08-25 00:09:27 +08:00
github-actions[bot] ed951ff0e1 chore: update beta formula for v1.0.60-beta.2 [skip ci] 2026-08-24 15:57:43 +00:00
赤川 b42596214d Merge pull request #1133 from DingTalk-Real-AI/codex/changelog-v1.0.60-beta.2-amendment
docs: amend v1.0.60-beta.2 release notes
2026-08-24 23:42:06 +08:00
chichuan f684c412ea docs: amend v1.0.60-beta.2 release notes 2026-08-24 23:37:21 +08:00
克谨 a4813f78bc fix(todo): preserve priority schema compatibility 2026-08-24 23:33:43 +08:00
赤川 415d962371 Merge pull request #1131 from DingTalk-Real-AI/codex/fix-pretag-beta-seal-amendment
fix(policy): allow audited pre-tag beta amendments
2026-08-24 23:13:59 +08:00
chichuan 26253a4eeb fix(policy): allow audited pre-tag beta amendments 2026-08-24 23:11:30 +08:00
克谨 46fdaed90b Merge remote-tracking branch 'origin/main' into codex/fix-todo-upstream-internal-error 2026-08-24 23:10:17 +08:00
克谨 9ea19315e9 fix(todo): classify upstream failures and validate inputs 2026-08-24 23:04:45 +08:00
github-actions[bot] 8549a90402 Merge pull request #1127 from DingTalk-Real-AI/codex/param-hallucination-6-products
feat(cli): govern six product parameter aliases
2026-08-24 22:59:59 +08:00
liyuan ab883f11f0 fix(report): require --to-user-ids on entry submit
Reject report submissions without visible recipients at the CLI layer
(Cobra required flag + fail-closed on blank values) while keeping the
openAPI create_report parameter optional (bug 85724185).
2026-08-24 21:14:40 +08:00
liyuan 7101ffc89c chore: approve report to-user-ids requiredness migrations (#85724185) 2026-08-24 21:12:18 +08:00
liyuan 4381a54efa test: adapt mock_mcp download e2e and coverage to proxy-free secure client 2026-08-24 21:00:00 +08:00
赤川 4fb3349dcd Merge branch 'main' into fix/remove-download-domain-allowlist 2026-08-24 19:11:03 +08:00
liyuan de7591be9c 专属地址下载链路问题修复 2026-08-24 16:39:07 +08:00
nitonitori 0d8008dabf chore: 调整错误引导至 dws doc import. 2026-08-24 15:15:11 +08:00
nitonitori ce8fe16f50 feat: 优化 markwodn 切分时对 html 标签的处理. 2026-08-24 15:15:11 +08:00
nitonitori 1ba326279f fix(markdown): 修复并优化Markdown追加内容分片逻辑
- 重新实现Markdown追加分片,确保每个分片为完整独立的顶级区块序列,满足update_document追加模式要求
- 修复分片过程中丢失换行符导致标题等Markdown元素识别错误的问题
- 解决超大表格和代码块被截断在单元格或代码围栏中间的问题
- 修正读取回写验证,避免与服务器未接收的内容比较导致验证失败
- 统一多条Markdown写入路径,统一分片逻辑与分片大小限制(30000字符)
- doc update --index参数在内容需分片时改为失败,避免不可预期的插入位置
- 添加分片写入过程中的降级变更通知字段,明确分片对文档结构的影响
- 优化写入流水线,增强超长内容自动分片支持和错误处理逻辑
- 补充分片相关的单元测试,保障内容完整性及边界拆分行为
2026-08-24 15:15:11 +08:00
赤川 e841d41640 Merge branch 'main' into codex/fix-ci-baseline-cache-governance 2026-08-20 20:38:39 +08:00
chichuan 7b5e3e2d1f ci: harden coverage baseline governance 2026-08-20 16:57:51 +08:00
232 changed files with 21878 additions and 19272 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Document shortcut reliability** — adds bounded pagination for document and template listings, supports verified paragraph or heading insertion before a reference block, tolerates service-only Markdown layout normalization during write verification, and resolves and verifies the default “My Documents” import target.
@@ -0,0 +1,31 @@
---
category: Changed
---
- **Download host trust policy** — retires the static DingTalk/OSS download
host allowlist, the dial-time public-IP refusal, and the IP-literal
refusal from both the shared local download path (`drive +download`,
`drive +version-download`, doc/minutes artifact downloads) and the chat
message-resource path (`chat +messages-resource-download`,
`--download-resources`). Download URLs only require HTTPS without userinfo
and accept non-default HTTPS ports, because every dimension of a
dedicated-deployment storage endpoint — custom domain, port, and network
location — is decided by the customer deployment and cannot be enumerated
or configured client-side. Verified on a dedicated deployment whose
storage domain resolves to a customer-intranet address. Downloads align
with the official GUI client, which applies no client-side SSRF
interception: download URLs only ever come from authenticated service
responses (no command accepts a user-supplied URL), TLS hostname
verification pins the connection to the requested host, redirects are
re-validated per hop, and service credential headers are stripped once a
redirect leaves the original origin.
- **Upload host trust unchanged** — upload target URLs (`drive +upload`,
minutes audio upload) keep the pre-existing public DingTalk/OSS trusted
host requirement through a dedicated upload validator, so removing the
download allowlist does not widen where local file bytes can be sent;
the validator also keeps the pre-existing default-port-only HTTPS rule
(DingTalk/OSS upload endpoints always serve on 443, so non-default ports
accepted for dedicated-deployment downloads stay anomalous for uploads).
Download credential headers are issued together with the download URL by
the same authenticated service response and follow it as-is on the first
request; redirects leaving the original host still strip them.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Fork pull-request admission** — keeps the read-only Reviewer Router identity check fail-closed while allowing external contributors' CI to use the reviewed public App slug when GitHub withholds repository variables.
+10
View File
@@ -0,0 +1,10 @@
---
category: Fixed
---
- **Markdown append chunking rewritten around safe split positions** — long markdown is now split so that every chunk is a complete, self-contained top-level block sequence, which is what `update_document mode=append` requires: the server inserts a brand new structure per call and cannot continue the previous one. Split points are chosen strictly by how much they change the rendered document — fully safe boundaries (blank lines, block starts that interrupt a paragraph) before boundaries that need repair (a table's rows now carry a re-emitted header and delimiter row; a fenced code block is closed and reopened with its original marker and info string) before boundaries that merely restructure (long paragraphs, list items) before a hard character cut. Within a tier the latest boundary in the window wins, since all chunks land in the same document. Every boundary that changes the rendered structure is reported in a new `degradations` field instead of being applied silently.
- **Fixed markdown chunking dropping a newline** — the previous splitter rebuilt block text from lines and lost one `\n` whenever the content's last line began a heading, table or code fence, so `"para\n# Title"` was written as `"para# Title"` and the heading stopped being a heading. Roughly one in five randomly generated documents was affected. The new splitter slices by offset and never rebuilds text, making content preservation structural.
- **Fixed oversized tables and code blocks being cut mid-cell and mid-fence** — the hard-split path never received the block type, so it cut at arbitrary character boundaries despite claiming to preserve table and code block integrity.
- **Fixed readback verification comparing against content the server never receives** — `doc +create` / `doc +update` verified the readback against the raw input, so any repaired boundary (and, previously, any paragraph split) failed verification on large documents. Verification now compares against the document the chunk plan says the server should hold.
- **Unified four markdown write paths onto one splitter** — `doc create` / `doc update`, `doc +create` / `doc +update` and `doc +checkpoint-update` now share `helpers.SplitMarkdownForAppend` and one limit constant (30000 runes), replacing two independent implementations plus one path that never chunked at all. `doc +checkpoint-update` accepts `@file` and stdin content, so oversized input was reachable there while the equivalent `doc +update` chunked. `doc +doc-append` takes `--text` from argv only and now rejects oversized input with a pointer to `doc +update` rather than sending one oversized call.
- **`doc update --index` now fails closed when the content requires chunking** — each chunk creates an unpredictable number of blocks, so the insertion point for later chunks is unknowable; the flag was previously accepted and silently ignored.
@@ -0,0 +1,5 @@
---
category: Removed
---
- **Education and college vendor extensions removed** — removes `dws edu-contact`, `dws edu-group`, `dws edu-app`, `dws edu-familygroup`, and `dws college-contact` from the CLI, Schema, bundled Skills, and open-edition MCP endpoint registry. Future DWS packages no longer expose these five command surfaces.
@@ -0,0 +1,5 @@
---
category: Changed
---
- **report entry submit requires recipients** — `dws report entry submit`(及废弃别名 `dws report create`)的 `--to-user-ids` 从可选提升为必填:无接收人的日志提交在服务端仍返回成功,但日志对任何接收人都不可见。openAPI `create_report` 的 `toUserIds` 参数保持可选不动,规则仅在 dws CLI 侧收紧——Cobra required 拦截未传场景,RunE 内对空值/纯分隔符(如 `--to-user-ids ","`)同样 fail-closed 拒绝。修复 [#85724185](https://project.aone.alibaba-inc.com/v2/project/2170318/bug/85724185)。
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Reviewer Router merge recovery** — retries exact App-owned merge intents through a SHA-bound synchronous merge after GitHub has enforced approval and nine GitHub Actions source-bound required checks.
+10
View File
@@ -4,3 +4,13 @@ paths:
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
# bundled workflow schema has not caught up with the platform syntax.
- 'unexpected key "queue" for "concurrency" section'
.github/workflows/coverage-baseline-promotion.yml:
ignore:
# Serialize every acknowledgement for one Formula target without
# allowing Actions' default single-pending replacement to orphan a run.
- 'unexpected key "queue" for "concurrency" section'
.github/workflows/coverage-baseline-repair.yml:
ignore:
# Keep the closed-event dispatcher and its exact-SHA producer queued for
# the same target instead of replacing either half of the repair chain.
- 'unexpected key "queue" for "concurrency" section'
+461 -4
View File
@@ -5,12 +5,17 @@ on:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Keep only the latest revision of a pull request: a stale run must not
# compete with its replacement for hosted runners. A replacement that sees
# a cold baseline cache recomputes it authoritatively. Protected-main pushes
# remain keyed by exact SHA so every potential merge base has a producer.
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
cancel-in-progress: true
jobs:
@@ -837,7 +842,9 @@ jobs:
if: ${{ always() && needs.lint.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
permissions:
contents: read
pull-requests: read
steps:
- name: Verify test shards
env:
@@ -916,6 +923,296 @@ jobs:
done
test "$failed" -eq 0
# Null and non-built-in merge identities emit either the protected-main
# push or the trusted pull_request_target closed repair. The built-in
# Actions identity is the exceptional unsafe path, so its own token must
# prove that main-merge-writers never lets it update main.
- name: Verify auto-merge identity
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const eventBaseSha = context.payload.pull_request.base.sha;
const configuredAppSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim();
const reviewedForkAppSlug = 'dingtalk-dws-reviewer-router';
const pullHeadRepository =
context.payload.pull_request.head.repo.full_name?.toLowerCase();
const baseRepository = `${owner}/${repo}`.toLowerCase();
const isForkPull =
Boolean(pullHeadRepository) && pullHeadRepository !== baseRepository;
const appSlug =
configuredAppSlug || (isForkPull ? reviewedForkAppSlug : '');
if (
!appSlug ||
appSlug !== appSlug.toLowerCase() ||
appSlug === 'github-actions'
) {
core.setFailed(
'Reviewer Router App slug repository variable is missing or unsafe.',
);
return;
}
if (!configuredAppSlug) {
core.info(
`Fork pull request cannot read the repository App slug variable; using the reviewed public slug ${reviewedForkAppSlug}.`,
);
}
const expectedAppOwner = `${appSlug}[bot]`;
const writerRulesetName = 'main-merge-writers';
const skipWorkflowPattern =
/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i;
const {data: repository} = await github.rest.repos.get({owner, repo});
function classifyMergeDefaults(repository) {
if (
repository === null ||
typeof repository !== 'object' ||
Array.isArray(repository)
) {
return 'invalid';
}
const hasTitle = Object.prototype.hasOwnProperty.call(
repository,
'merge_commit_title',
);
const hasMessage = Object.prototype.hasOwnProperty.call(
repository,
'merge_commit_message',
);
if (!hasTitle && !hasMessage) {
return 'omitted';
}
if (!hasTitle || !hasMessage) {
return 'invalid';
}
if (
repository.merge_commit_title === 'MERGE_MESSAGE' &&
['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)
) {
return 'reviewed';
}
return 'invalid';
}
const mergeDefaultsProjection = classifyMergeDefaults(repository);
if (mergeDefaultsProjection === 'invalid') {
core.setFailed(
'Repository merge-message defaults are malformed or changed from their reviewed values.',
);
return;
}
if (mergeDefaultsProjection === 'omitted') {
core.info(
'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.',
);
}
const appliedRules = await github.paginate(
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
{owner, repo, branch: 'main', per_page: 100},
);
const repositorySource = `${owner}/${repo}`.toLowerCase();
const applicableRulesetIDs = [
...new Set(
appliedRules
.filter(rule =>
rule.ruleset_source_type === 'Repository' &&
rule.ruleset_source?.toLowerCase() === repositorySource &&
Number.isSafeInteger(Number(rule.ruleset_id)) &&
Number(rule.ruleset_id) > 0,
)
.map(rule => Number(rule.ruleset_id)),
),
];
const activeMainRulesets = [];
for (const rulesetID of applicableRulesetIDs) {
const {data: ruleset} = await github.request(
'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}',
{owner, repo, ruleset_id: rulesetID},
);
if (
ruleset.enforcement !== 'active' ||
ruleset.target !== 'branch' ||
ruleset.source_type !== 'Repository' ||
ruleset.source?.toLowerCase() !== repositorySource
) {
core.setFailed(
`Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`,
);
return;
}
activeMainRulesets.push(ruleset);
}
const writerRulesets = activeMainRulesets.filter(
ruleset => ruleset.name === writerRulesetName,
);
if (writerRulesets.length !== 1) {
core.setFailed(
`Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`,
);
return;
}
const writerRuleset = writerRulesets[0];
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
// GitHub's read projection omits the entire parameters property
// when this exception is disabled. Accept only that exact omission
// or a one-field object containing exact false.
function isStrictUpdateRule(rule) {
if (rule?.type !== 'update') {
return false;
}
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
return true;
}
const parameters = rule.parameters;
if (
parameters === null ||
typeof parameters !== 'object' ||
Array.isArray(parameters)
) {
return false;
}
const parameterKeys = Object.keys(parameters);
return (
parameterKeys.length === 1 &&
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
parameters.update_allows_fetch_and_merge === false
);
}
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
const restRulesetID = Number(restRuleset?.id);
const graphRulesetID = Number(graphRuleset?.databaseId);
const graphRules = graphRuleset?.rules;
const graphRule = graphRules?.nodes?.[0];
return (
Number.isSafeInteger(restRulesetID) &&
restRulesetID > 0 &&
graphRulesetID === restRulesetID &&
graphRuleset.name === restRuleset.name &&
graphRuleset.enforcement === 'ACTIVE' &&
graphRuleset.target === 'BRANCH' &&
graphRules?.totalCount === 1 &&
graphRules.nodes?.length === 1 &&
graphRule?.type === 'UPDATE' &&
graphRule.parameters?.__typename === 'UpdateParameters' &&
graphRule.parameters.updateAllowsFetchAndMerge === false
);
}
if (
writerIncludes.length !== 1 ||
writerIncludes[0] !== 'refs/heads/main' ||
writerExcludes.length !== 0 ||
typeof writerRuleset.node_id !== 'string' ||
!writerRuleset.node_id ||
writerRuleset.rules?.length !== 1 ||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
writerRuleset.current_user_can_bypass !== 'never'
) {
core.setFailed(
`${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`,
);
return;
}
const {node: graphWriterRuleset} = await github.graphql(
`query ReviewerRouterWriterRule($rulesetID: ID!) {
node(id: $rulesetID) {
... on RepositoryRuleset {
databaseId
name
enforcement
target
rules(first: 2) {
totalCount
nodes {
type
parameters {
__typename
... on UpdateParameters {
updateAllowsFetchAndMerge
}
}
}
}
}
}
}`,
{rulesetID: writerRuleset.node_id},
);
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
core.setFailed(
`${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`,
);
return;
}
const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (
currentPull.head.sha !== eventHeadSha ||
currentPull.base.sha !== eventBaseSha ||
currentPull.state !== 'open' ||
currentPull.draft ||
currentPull.base.ref !== 'main'
) {
core.setFailed(
`PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`,
);
return;
}
const mergeTexts = [
currentPull.title,
currentPull.auto_merge?.commit_title,
currentPull.auto_merge?.commit_message,
].filter(value => typeof value === 'string');
if (mergeTexts.some(value => skipWorkflowPattern.test(value))) {
core.setFailed(
`PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`,
);
return;
}
if (!currentPull.auto_merge) {
core.info(
`PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`,
);
return;
}
const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase();
const safeCommitHeadline = `Merge pull request #${pullNumber}`;
const safeCommitBody =
`Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`;
if (
enabledBy === expectedAppOwner &&
currentPull.auto_merge.commit_title === safeCommitHeadline &&
currentPull.auto_merge.commit_message === safeCommitBody
) {
core.info(
`PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`,
);
return;
}
if (attempt < maxAttempts) {
core.info(
`PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`,
);
await new Promise(resolve => setTimeout(resolve, 5000));
continue;
}
core.setFailed(
`PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`,
);
}
test-darwin:
name: Test (macOS auth/keychain)
needs: lint
@@ -1246,7 +1543,7 @@ jobs:
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -1381,6 +1678,143 @@ jobs:
path: coverage-base.txt
retention-days: 1
# Documentation and release-seal pushes do not change executable coverage,
# but their new main SHA is still a future PR merge base. Promote only an
# exact predecessor cache after independently proving the whole push changed
# metadata paths; fall back to a full authoritative profile on a cold chain.
coverage-main-metadata:
name: Coverage (main metadata cache)
needs: lint
if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out exact metadata-only main revision
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Set up Go
id: setup-go-metadata
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify metadata-only main successor
shell: bash
env:
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_AFTER_SHA: ${{ github.event.after }}
run: |
set -euo pipefail
full_commit='^[0-9a-f]{40}$'
[[ "$PUSH_BEFORE_SHA" =~ $full_commit ]]
[[ "$PUSH_AFTER_SHA" =~ $full_commit ]]
test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000
test "$PUSH_AFTER_SHA" = "$GITHUB_SHA"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null
git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
changed_count=0
while IFS= read -r -d '' path; do
changed_count=$((changed_count + 1))
case "$path" in
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*)
;;
.changes/*)
if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] ||
[[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then
continue
fi
echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2
exit 1
;;
*)
echo "Refusing coverage-cache promotion for executable path: $path" >&2
exit 1
;;
esac
done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA")
test "$changed_count" -gt 0
echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV"
- name: Restore existing current-SHA coverage profile
id: metadata-current-cache
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
- name: Validate existing current-SHA coverage profile
if: steps.metadata-current-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Restore exact predecessor coverage profile
id: metadata-source-cache
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}
- name: Validate promoted predecessor coverage profile
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Install archive tooling for cold metadata baseline
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Recompute cold metadata baseline
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
go test -count=1 -p 1 \
-coverprofile=coverage-cache.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Save metadata main SHA coverage profile
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
# actions/cache/save reports upload failures as warnings. Convert an
# absent exact target key into a hard producer failure.
- name: Verify metadata main SHA coverage cache exists
id: metadata-target-cache-verification
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
lookup-only: true
fail-on-cache-miss: true
- name: Require exact metadata main SHA coverage cache
env:
EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}
run: test "$EXACT_CACHE_HIT" = true
coverage:
name: Coverage
needs:
@@ -1389,6 +1823,7 @@ jobs:
- coverage-current-full
- coverage-supporting
- coverage-baseline
- coverage-main-metadata
- coverage-darwin
- coverage-windows
if: ${{ always() && needs.lint.result == 'success' }}
@@ -1405,6 +1840,7 @@ jobs:
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
run: |
@@ -1413,10 +1849,14 @@ jobs:
current_full_expected=skipped
supporting_expected=skipped
baseline_expected=success
main_metadata_expected=skipped
native_expected=skipped
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
current_expected=skipped
baseline_expected=skipped
if [ "$GITHUB_EVENT_NAME" = push ]; then
main_metadata_expected=success
fi
elif [ "$FULL_SUITE" = true ]; then
current_expected=skipped
current_full_expected=success
@@ -1432,7 +1872,8 @@ jobs:
"current:$CURRENT_RESULT:$current_expected" \
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
"baseline:$BASELINE_RESULT:$baseline_expected"
"baseline:$BASELINE_RESULT:$baseline_expected" \
"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"
do
name="${profile%%:*}"
remainder="${profile#*:}"
@@ -1572,6 +2013,22 @@ jobs:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Verify push coverage cache exists
id: push-cache-verification
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
lookup-only: true
fail-on-cache-miss: true
- name: Require exact push coverage cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
env:
EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}
run: test "$EXACT_CACHE_HIT" = true
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
run: |
@@ -0,0 +1,322 @@
name: Coverage Baseline Promotion
run-name: Promote coverage baseline for ${{ github.event.client_payload.target_sha }}
on:
repository_dispatch:
types: [coverage-baseline-promote]
# repository_dispatch loads this workflow from the protected default branch.
# The requested target is treated as untrusted input until the validation step
# proves it is an exact Formula-only successor already contained in main.
permissions:
checks: write
contents: read
concurrency:
group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}
cancel-in-progress: false
queue: max
jobs:
promote:
if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Validate Formula-only main target
id: validate-target
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const targetSha = context.payload.client_payload?.target_sha;
const sourceRunId = context.payload.client_payload?.source_run_id;
const checkRunId = Number(context.payload.client_payload?.check_run_id);
if (!/^[0-9a-f]{40}$/.test(targetSha || '')) {
throw new Error('coverage-baseline-promote requires one full target_sha');
}
if (!/^[1-9][0-9]*$/.test(sourceRunId || '')) {
throw new Error('coverage-baseline-promote requires one source_run_id');
}
if (!Number.isSafeInteger(checkRunId) || checkRunId <= 0) {
throw new Error('coverage-baseline-promote requires one safe check_run_id');
}
// Bind the finalizer before any target or cache validation. A
// later failure must complete the release-created acknowledgement
// instead of leaving Release to poll a permanently queued check.
const promotionExternalId = `release-${sourceRunId}-${targetSha}`;
const {data: promotionCheck} = await github.rest.checks.get({
owner,
repo,
check_run_id: checkRunId,
});
if (
promotionCheck.id !== checkRunId ||
promotionCheck.head_sha !== targetSha ||
promotionCheck.name !== 'Coverage Baseline Cache' ||
promotionCheck.external_id !== promotionExternalId ||
promotionCheck.app?.slug !== 'github-actions' ||
promotionCheck.status !== 'queued' ||
promotionCheck.conclusion !== null
) {
throw new Error('coverage baseline acknowledgement has an invalid identity');
}
core.setOutput('target_sha', targetSha);
core.setOutput('check_run_id', String(checkRunId));
core.setOutput('check_external_id', promotionExternalId);
const {data: targetCommit} = await github.rest.repos.getCommit({
owner,
repo,
ref: targetSha,
per_page: 100,
});
const files = targetCommit.files || [];
const message = targetCommit.commit.message;
const formulaPath = files[0]?.filename;
const stableFormula =
formulaPath === 'Formula/dingtalk-workspace-cli.rb' &&
/^chore: update formula for v[0-9]+\.[0-9]+\.[0-9]+ \[skip ci\]$/.test(message);
const betaFormula =
formulaPath === 'Formula/dingtalk-workspace-cli-beta.rb' &&
/^chore: update beta formula for v[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]* \[skip ci\]$/.test(message);
if (
targetCommit.sha !== targetSha ||
targetCommit.parents.length !== 1 ||
targetCommit.author?.login !== 'github-actions[bot]' ||
targetCommit.committer?.login !== 'github-actions[bot]' ||
files.length !== 1 ||
!['added', 'modified'].includes(files[0].status) ||
(!stableFormula && !betaFormula)
) {
throw new Error(
`${targetSha} is not an exact release-produced Formula-only commit`,
);
}
const parentSha = targetCommit.parents[0].sha;
const requiredContexts = [
'Lint',
'Test',
'Coverage',
'Policy',
'Edition',
'Interface Integrity',
'AI Behavior',
'CLI Smoke',
'Mock MCP',
];
async function requireSuccessfulAdmission(ref, label) {
for (let attempt = 1; attempt <= 6; attempt += 1) {
const runs = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref,
filter: 'latest',
per_page: 100,
});
const latestByName = new Map();
for (const run of runs) {
if (
run.head_sha !== ref ||
run.app?.slug !== 'github-actions' ||
!requiredContexts.includes(run.name)
) {
continue;
}
const current = latestByName.get(run.name);
if (!current || run.id > current.id) {
latestByName.set(run.name, run);
}
}
const invalid = requiredContexts.filter((name) => {
const run = latestByName.get(name);
return !run || run.conclusion !== 'success';
});
if (invalid.length === 0) {
return;
}
if (attempt < 6) {
await new Promise(resolve => setTimeout(resolve, 5000));
continue;
}
throw new Error(
`${label} ${ref} lacks successful Code Admission contexts: ${invalid.join(', ')}`,
);
}
}
await requireSuccessfulAdmission(parentSha, 'Formula parent');
await requireSuccessfulAdmission(targetSha, 'Formula target');
const {data: branch} = await github.rest.repos.getBranch({
owner,
repo,
branch: context.payload.repository.default_branch,
});
const {data: containment} =
await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${targetSha}...${branch.commit.sha}`,
});
if (!['ahead', 'identical'].includes(containment.status)) {
throw new Error(`${targetSha} is not contained in the protected default branch`);
}
core.setOutput('parent_sha', parentSha);
core.setOutput('formula_path', formulaPath);
- name: Mark Formula cache promotion in progress
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
await github.rest.checks.update({
...context.repo,
check_run_id: Number('${{ steps.validate-target.outputs.check_run_id }}'),
status: 'in_progress',
started_at: new Date().toISOString(),
output: {
title: 'Producing exact-SHA coverage baseline',
summary: 'The trusted default-branch workflow is validating or producing the main-scoped cache.',
},
});
- name: Check out validated Formula-only target
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ steps.validate-target.outputs.target_sha }}
- name: Verify checked-out Formula-only identity
shell: bash
env:
TARGET_SHA: ${{ steps.validate-target.outputs.target_sha }}
PARENT_SHA: ${{ steps.validate-target.outputs.parent_sha }}
FORMULA_PATH: ${{ steps.validate-target.outputs.formula_path }}
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$TARGET_SHA"
test "$(git rev-parse HEAD^)" = "$PARENT_SHA"
test "$(git diff --name-only --no-renames "$PARENT_SHA" "$TARGET_SHA")" = "$FORMULA_PATH"
- name: Set up Go
id: setup-go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Restore existing target coverage profile
id: target-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Validate existing target coverage profile
if: steps.target-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Restore exact Formula parent coverage profile
id: parent-cache
if: steps.target-cache.outputs.cache-hit != 'true'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Validate promoted Formula parent profile
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Install archive tooling for cold Formula baseline
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Recompute cold Formula baseline
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
go test -count=1 -p 1 \
-coverprofile=coverage-cache.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Save Formula main SHA coverage profile
if: steps.target-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Verify Formula main SHA coverage cache exists
id: formula-target-cache-verification
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
lookup-only: true
fail-on-cache-miss: true
- name: Require exact Formula main SHA coverage cache
env:
EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}
run: test "$EXACT_CACHE_HIT" = true
- name: Complete Formula cache promotion acknowledgement
if: ${{ always() && steps.validate-target.outputs.check_run_id != '' }}
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
PROMOTION_JOB_STATUS: ${{ job.status }}
with:
script: |
const checkRunId = Number('${{ steps.validate-target.outputs.check_run_id }}');
const targetSha = '${{ steps.validate-target.outputs.target_sha }}';
const expectedExternalId = '${{ steps.validate-target.outputs.check_external_id }}';
const {data: currentCheck} = await github.rest.checks.get({
...context.repo,
check_run_id: checkRunId,
});
if (
currentCheck.head_sha !== targetSha ||
currentCheck.name !== 'Coverage Baseline Cache' ||
currentCheck.external_id !== expectedExternalId ||
currentCheck.app?.slug !== 'github-actions'
) {
throw new Error('refusing to update a changed promotion acknowledgement');
}
const succeeded = process.env.PROMOTION_JOB_STATUS === 'success';
await github.rest.checks.update({
...context.repo,
check_run_id: checkRunId,
status: 'completed',
conclusion: succeeded ? 'success' : 'failure',
completed_at: new Date().toISOString(),
output: {
title: succeeded
? 'Exact-SHA coverage baseline is available'
: 'Exact-SHA coverage baseline promotion failed',
summary: succeeded
? `Verified the main-scoped exact cache for ${targetSha}.`
: `Promotion failed for ${targetSha}; rerun the failed Release job after correcting the producer.`,
},
});
@@ -0,0 +1,560 @@
name: Coverage Baseline Repair
run-name: Repair coverage baseline from ${{ github.event_name }}
on:
pull_request_target:
branches: [main]
types: [closed]
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
repository_dispatch:
types: [coverage-baseline-repair]
schedule:
- cron: "23 * * * *"
workflow_dispatch:
# pull_request_target and workflow_run are allowed to inspect only GitHub API
# data and dispatch the trusted producer. GitHub deliberately makes both
# triggers read-only for the default-branch cache, so all checkout and cache
# writes live in repository_dispatch, schedule, or main-only workflow_dispatch.
permissions:
contents: read
concurrency:
group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}
cancel-in-progress: false
# Retain every pending repair for one target. actionlint v1.7.12's bundled
# schema predates GitHub's concurrency.queue support.
queue: max
jobs:
dispatch-merged-pr:
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: write
pull-requests: read
steps:
# Never check out or execute pull-request content in this privileged
# base-owned event. Re-read the merged PR, bind every immutable identity,
# prove the result is in main, and send only those values to the producer.
- name: Dispatch trusted merged-PR repair
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const eventPull = context.payload.pull_request;
const fullCommit = /^[0-9a-f]{40}$/;
const pullNumber = Number(eventPull?.number);
const headSha = eventPull?.head?.sha;
const baseRef = eventPull?.base?.ref;
const mergeCommitSha = eventPull?.merge_commit_sha;
if (
context.payload.repository?.full_name !==
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
context.payload.repository?.default_branch !== 'main' ||
!Number.isSafeInteger(pullNumber) ||
pullNumber <= 0 ||
!fullCommit.test(headSha || '') ||
baseRef !== 'main' ||
!fullCommit.test(mergeCommitSha || '')
) {
throw new Error('closed PR event has an invalid repository or revision identity');
}
// REST base.sha follows the live base branch and can move after
// merge. Bind the closed event's stable PR head snapshot and merge
// facts, then authorize the target through main containment.
function isStableMergedPRIdentity(
currentPull,
pullNumber,
headSha,
mergeCommitSha,
) {
return (
currentPull?.number === pullNumber &&
currentPull.state === 'closed' &&
currentPull.merged === true &&
typeof currentPull.merged_at === 'string' &&
currentPull.merged_at.length > 0 &&
currentPull.base?.ref === 'main' &&
currentPull.head?.sha === headSha &&
currentPull.merge_commit_sha === mergeCommitSha
);
}
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (!isStableMergedPRIdentity(
currentPull,
pullNumber,
headSha,
mergeCommitSha,
)) {
throw new Error(`PR #${pullNumber} no longer matches the merged-main event`);
}
async function requireMainContainment(targetSha) {
let lastState = 'not checked';
for (let attempt = 1; attempt <= 6; attempt += 1) {
try {
const {data: branch} = await github.rest.repos.getBranch({
owner,
repo,
branch: 'main',
});
const {data: comparison} =
await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${targetSha}...${branch.commit.sha}`,
});
lastState = comparison.status;
if (['ahead', 'identical'].includes(comparison.status)) {
return;
}
} catch (error) {
lastState = error.message;
}
if (attempt < 6) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
throw new Error(
`${targetSha} is not contained in protected main after retries: ${lastState}`,
);
}
await requireMainContainment(mergeCommitSha);
// Normal App or human merges emit a protected-main push run whose
// CI producer owns this exact key. Give Actions event delivery a
// short visibility window and avoid a duplicate full-suite repair.
// A workflow-skip directive or suppressed built-in-token event has
// no such run, so only that missing-event path reaches dispatch.
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
owner,
repo,
workflow_id: '.github/workflows/ci.yml',
});
if (
ciWorkflow.name !== 'CI' ||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
ciWorkflow.state !== 'active'
) {
throw new Error('protected CI workflow identity is not active or exact');
}
for (let attempt = 1; attempt <= 12; attempt += 1) {
const {data: workflowRuns} =
await github.rest.actions.listWorkflowRunsForRepo({
owner,
repo,
branch: 'main',
event: 'push',
per_page: 100,
});
const exactPushRun = workflowRuns.workflow_runs.find(run =>
run.name === 'CI' &&
run.workflow_id === ciWorkflow.id &&
run.path === ciWorkflow.path &&
run.event === 'push' &&
run.head_sha === mergeCommitSha &&
run.head_branch === 'main' &&
['queued', 'in_progress', 'completed'].includes(run.status),
);
if (exactPushRun) {
core.info(
`CI push run ${exactPushRun.id} already owns the exact-SHA producer for ${mergeCommitSha}; repair dispatch is unnecessary.`,
);
return;
}
if (attempt < 12) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
// repository_dispatch is one of GitHub's explicit GITHUB_TOKEN
// recursion exceptions and receives default-branch cache-write scope.
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'coverage-baseline-repair',
client_payload: {
source: 'merged_pr',
pull_number: String(pullNumber),
head_sha: headSha,
merge_commit_sha: mergeCommitSha,
source_run_id: String(context.runId),
},
});
core.info(
`Dispatched exact-SHA coverage repair for merged PR #${pullNumber} at ${mergeCommitSha}.`,
);
dispatch-failed-ci:
if: >-
${{
github.event_name == 'workflow_run' &&
github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' &&
github.event.workflow_run.name == 'CI' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.status == 'completed' &&
github.event.workflow_run.conclusion != 'success'
}}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: write
steps:
# workflow_run cannot write the default-branch cache. Re-read the exact
# completed CI run from Actions, bind it to the protected CI workflow and
# main revision, then use the repository_dispatch recursion exception.
- name: Dispatch trusted failed-CI repair
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const upstream = 'DingTalk-Real-AI/dingtalk-workspace-cli';
const eventRun = context.payload.workflow_run;
const fullCommit = /^[0-9a-f]{40}$/;
const runID = Number(eventRun?.id);
const runAttempt = Number(eventRun?.run_attempt);
const headSha = eventRun?.head_sha;
const conclusion = eventRun?.conclusion;
if (
context.payload.repository?.full_name !== upstream ||
context.payload.repository?.default_branch !== 'main' ||
!Number.isSafeInteger(runID) ||
runID <= 0 ||
!Number.isSafeInteger(runAttempt) ||
runAttempt <= 0 ||
eventRun?.name !== 'CI' ||
eventRun?.event !== 'push' ||
eventRun?.head_branch !== 'main' ||
eventRun?.status !== 'completed' ||
typeof conclusion !== 'string' ||
conclusion.length === 0 ||
conclusion === 'success' ||
!fullCommit.test(headSha || '')
) {
throw new Error('workflow_run event is not one completed non-success main CI push');
}
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
owner,
repo,
workflow_id: '.github/workflows/ci.yml',
});
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: runID,
});
if (
ciWorkflow.name !== 'CI' ||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
eventRun.workflow_id !== ciWorkflow.id ||
currentRun.id !== runID ||
currentRun.workflow_id !== ciWorkflow.id ||
currentRun.name !== 'CI' ||
currentRun.event !== 'push' ||
currentRun.head_branch !== 'main' ||
currentRun.head_sha !== headSha ||
currentRun.run_attempt !== runAttempt ||
currentRun.status !== 'completed' ||
currentRun.conclusion !== conclusion ||
currentRun.conclusion === 'success' ||
currentRun.repository?.full_name !== upstream ||
currentRun.head_repository?.full_name !== upstream
) {
throw new Error(`CI workflow run ${runID} no longer matches the completed event`);
}
await github.rest.repos.createDispatchEvent({
owner,
repo,
event_type: 'coverage-baseline-repair',
client_payload: {
source: 'failed_ci',
workflow_run_id: String(runID),
workflow_run_attempt: String(runAttempt),
workflow_conclusion: conclusion,
merge_commit_sha: headSha,
source_run_id: String(context.runId),
},
});
core.info(
`Dispatched exact-SHA coverage repair for ${conclusion} CI run ${runID} at ${headSha}.`,
);
repair:
if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
actions: read
contents: read
pull-requests: read
steps:
- name: Resolve trusted main repair target
id: resolve-target
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const fullCommit = /^[0-9a-f]{40}$/;
if (
context.payload.repository?.full_name !==
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
context.payload.repository?.default_branch !== 'main'
) {
throw new Error('coverage repair is restricted to the protected upstream repository');
}
async function requireMainContainment(targetSha) {
let lastState = 'not checked';
for (let attempt = 1; attempt <= 6; attempt += 1) {
try {
const {data: branch} = await github.rest.repos.getBranch({
owner,
repo,
branch: 'main',
});
const {data: comparison} =
await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${targetSha}...${branch.commit.sha}`,
});
lastState = comparison.status;
if (['ahead', 'identical'].includes(comparison.status)) {
return branch.commit.sha;
}
} catch (error) {
lastState = error.message;
}
if (attempt < 6) {
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
throw new Error(
`${targetSha} is not contained in protected main after retries: ${lastState}`,
);
}
// The dispatcher froze the stable PR head snapshot in this payload.
// Do not re-read mutable base.sha; bind the head and stable merge
// facts, then prove protected-main containment below.
function isStableMergedPRIdentity(
currentPull,
pullNumber,
headSha,
mergeCommitSha,
) {
return (
currentPull?.number === pullNumber &&
currentPull.state === 'closed' &&
currentPull.merged === true &&
typeof currentPull.merged_at === 'string' &&
currentPull.merged_at.length > 0 &&
currentPull.base?.ref === 'main' &&
currentPull.head?.sha === headSha &&
currentPull.merge_commit_sha === mergeCommitSha
);
}
let targetSha;
if (context.eventName === 'repository_dispatch') {
const payload = context.payload.client_payload || {};
const sourceRunIDText = String(payload.source_run_id || '');
if (!/^[1-9][0-9]*$/.test(sourceRunIDText)) {
throw new Error('coverage-baseline-repair payload has an invalid source run');
}
if (payload.source === 'merged_pr') {
const rawPullNumber = String(payload.pull_number || '');
const pullNumber = Number(rawPullNumber);
const headSha = payload.head_sha;
targetSha = payload.merge_commit_sha;
if (
!/^[1-9][0-9]*$/.test(rawPullNumber) ||
!Number.isSafeInteger(pullNumber) ||
!fullCommit.test(headSha || '') ||
!fullCommit.test(targetSha || '')
) {
throw new Error('coverage-baseline-repair payload has an invalid PR identity');
}
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (!isStableMergedPRIdentity(
currentPull,
pullNumber,
headSha,
targetSha,
)) {
throw new Error(
`repair payload no longer matches merged PR #${pullNumber}`,
);
}
} else if (payload.source === 'failed_ci') {
const rawWorkflowRunID = String(payload.workflow_run_id || '');
const workflowRunID = Number(rawWorkflowRunID);
const rawWorkflowRunAttempt = String(payload.workflow_run_attempt || '');
const workflowRunAttempt = Number(rawWorkflowRunAttempt);
const workflowConclusion = payload.workflow_conclusion;
targetSha = payload.merge_commit_sha;
if (
!/^[1-9][0-9]*$/.test(rawWorkflowRunID) ||
!Number.isSafeInteger(workflowRunID) ||
!/^[1-9][0-9]*$/.test(rawWorkflowRunAttempt) ||
!Number.isSafeInteger(workflowRunAttempt) ||
typeof workflowConclusion !== 'string' ||
workflowConclusion.length === 0 ||
workflowConclusion === 'success' ||
!fullCommit.test(targetSha || '')
) {
throw new Error('coverage-baseline-repair payload has an invalid CI identity');
}
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
owner,
repo,
workflow_id: '.github/workflows/ci.yml',
});
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: workflowRunID,
});
if (
ciWorkflow.name !== 'CI' ||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
currentRun.id !== workflowRunID ||
currentRun.workflow_id !== ciWorkflow.id ||
currentRun.name !== 'CI' ||
currentRun.event !== 'push' ||
currentRun.head_branch !== 'main' ||
currentRun.head_sha !== targetSha ||
currentRun.run_attempt !== workflowRunAttempt ||
currentRun.status !== 'completed' ||
currentRun.conclusion !== workflowConclusion ||
currentRun.conclusion === 'success' ||
currentRun.repository?.full_name !==
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
currentRun.head_repository?.full_name !==
'DingTalk-Real-AI/dingtalk-workspace-cli'
) {
throw new Error(
`repair payload no longer matches failed CI run ${workflowRunID}`,
);
}
} else {
throw new Error('coverage-baseline-repair payload has an unknown source');
}
await requireMainContainment(targetSha);
} else {
if (context.ref !== 'refs/heads/main') {
throw new Error('scheduled and manual repair must run from refs/heads/main');
}
// github.sha is the default-branch tip that keyed this workflow's
// concurrency group. Keep the producer bound to that exact
// event-time target even if main advances while this run queues.
targetSha = context.sha;
if (!fullCommit.test(targetSha || '')) {
throw new Error('protected main did not resolve to one full commit SHA');
}
await requireMainContainment(targetSha);
}
core.setOutput('target_sha', targetSha);
core.info(`Resolved protected-main coverage repair target ${targetSha}.`);
- name: Check out exact protected-main target
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ steps.resolve-target.outputs.target_sha }}
- name: Verify checked-out repair target
env:
TARGET_SHA: ${{ steps.resolve-target.outputs.target_sha }}
run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"
- name: Set up Go
id: setup-go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Restore exact target coverage profile
id: target-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
- name: Validate existing exact target profile
if: steps.target-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Install archive tooling for cold repair
if: steps.target-cache.outputs.cache-hit != 'true'
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Recompute complete target coverage profile
if: steps.target-cache.outputs.cache-hit != 'true'
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
go test -count=1 -p 1 \
-coverprofile=coverage-cache.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Save exact protected-main coverage profile
if: steps.target-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
# Cache uploads are fail-open warnings. A lookup-only restore plus the
# explicit cache-hit assertion makes an absent or partial key fail hard.
- name: Verify exact protected-main coverage cache exists
id: target-cache-verification
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
lookup-only: true
fail-on-cache-miss: true
- name: Require exact protected-main coverage cache
env:
EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}
run: test "$EXACT_CACHE_HIT" = true
+130
View File
@@ -1112,6 +1112,9 @@ jobs:
needs: [release-contract, release-validation, release, verify-darwin-signatures]
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}
coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}
permissions:
checks: write
contents: write
@@ -1495,6 +1498,7 @@ jobs:
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
- name: Seal Formula-only Code Admission contexts
id: seal-formula
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
uses: actions/github-script@v7
env:
@@ -1515,6 +1519,8 @@ jobs:
const sourcePath = channel === "stable"
? "dist/homebrew/dingtalk-workspace-cli.rb"
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
core.setOutput("coverage_baseline_required", "false");
core.setOutput("coverage_baseline_commit", "");
const expectedMessage = channel === "stable"
? `chore: update formula for ${version} [skip ci]`
: `chore: update beta formula for ${version} [skip ci]`;
@@ -1650,6 +1656,11 @@ jobs:
},
});
}
core.setOutput("coverage_baseline_required", "true");
core.setOutput("coverage_baseline_commit", commit);
core.info(
`Formula-only Code Admission is sealed for ${commit}; the independent confirmation job will dispatch its exact-SHA cache producer.`,
);
- name: Reverify exact immutable npm package
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
@@ -2177,6 +2188,117 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
coverage-baseline-confirmation:
name: Confirm Formula coverage baseline
# Once Formula sealing has exposed a target SHA, later publication
# verification failures must not orphan its exact-main cache producer.
if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}
needs: publish-release
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
checks: write
contents: write
steps:
- name: Require exact Formula cache acknowledgement
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}
FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}
with:
script: |
const rawRequired = process.env.BASELINE_REQUIRED;
if (!['true', 'false'].includes(rawRequired)) {
throw new Error(`Formula baseline requirement is invalid: ${rawRequired || 'empty'}`);
}
const required = rawRequired === 'true';
const targetSha = process.env.FORMULA_COMMIT;
if (!required) {
if (targetSha) {
throw new Error('Formula baseline outputs are inconsistent for a no-op publication');
}
core.info('Formula was already current; no new exact-SHA cache acknowledgement is required.');
return;
}
if (!/^[0-9a-f]{40}$/.test(targetSha)) {
throw new Error('Formula baseline target output is malformed');
}
const expectedExternalId = `release-${context.runId}-${targetSha}`;
let promotionCheck;
try {
const created = await github.rest.checks.create({
...context.repo,
name: 'Coverage Baseline Cache',
head_sha: targetSha,
status: 'queued',
external_id: expectedExternalId,
output: {
title: 'Waiting for exact-SHA baseline promotion',
summary:
'The independent release governance job is waiting for the default-branch cache producer.',
},
});
promotionCheck = created.data;
await github.rest.repos.createDispatchEvent({
...context.repo,
event_type: 'coverage-baseline-promote',
client_payload: {
target_sha: targetSha,
source_run_id: String(context.runId),
check_run_id: String(promotionCheck.id),
},
});
} catch (error) {
if (promotionCheck) {
try {
await github.rest.checks.update({
...context.repo,
check_run_id: promotionCheck.id,
status: 'completed',
conclusion: 'failure',
completed_at: new Date().toISOString(),
output: {
title: 'Coverage baseline dispatch failed',
summary: `Release could not dispatch the exact-SHA producer: ${error.message}`,
},
});
} catch (cleanupError) {
core.error(
`Could not close failed cache acknowledgement ${promotionCheck.id}: ${cleanupError.message}`,
);
}
}
throw error;
}
const checkRunId = promotionCheck.id;
for (let attempt = 1; attempt <= 180; attempt += 1) {
const {data: currentCheck} = await github.rest.checks.get({
...context.repo,
check_run_id: checkRunId,
});
if (
currentCheck.head_sha !== targetSha ||
currentCheck.name !== 'Coverage Baseline Cache' ||
currentCheck.external_id !== expectedExternalId ||
currentCheck.app?.slug !== 'github-actions'
) {
throw new Error('Formula baseline promotion acknowledgement changed identity');
}
if (currentCheck.status === 'completed') {
if (currentCheck.conclusion !== 'success') {
throw new Error(
`Formula baseline promotion failed with ${currentCheck.conclusion || 'unknown'}`,
);
}
core.info(`Formula baseline promotion completed for ${targetSha}.`);
return;
}
if (attempt < 180) {
await new Promise(resolve => setTimeout(resolve, 10000));
}
}
throw new Error(`Formula baseline promotion timed out for ${targetSha}`);
release-delivery-gate:
name: Release delivery gate
if: ${{ !cancelled() }}
@@ -2189,6 +2311,7 @@ jobs:
- verify-darwin-signatures
- publish-release
- publish-channels
- coverage-baseline-confirmation
- mirror-gitee-release
- repair-npm
- repair-channel
@@ -2211,6 +2334,7 @@ jobs:
DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }}
PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }}
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
@@ -2234,6 +2358,7 @@ jobs:
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success
require_result publish-release "$PUBLISH_RELEASE_RESULT" success
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success
if test "$GITEE_FALLBACK_ENABLED" = true; then
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success
else
@@ -2273,6 +2398,7 @@ jobs:
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
@@ -2294,6 +2420,7 @@ jobs:
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
@@ -2309,6 +2436,7 @@ jobs:
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
@@ -2323,6 +2451,7 @@ jobs:
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_cloud_jobs_skipped
@@ -2337,6 +2466,7 @@ jobs:
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_cloud_jobs_skipped
@@ -0,0 +1,19 @@
name: Reviewer Router approval signal
on:
pull_request_review:
types: [submitted, dismissed]
# This workflow only converts an approval-state change into a trusted
# workflow_run event. It must never read secrets, check out code, or mutate the
# pull request; the default-branch Reviewer routing workflow owns reconciliation.
permissions: {}
jobs:
signal:
runs-on: ubuntu-latest
timeout-minutes: 1
permissions: {}
steps:
- name: Signal approval-state change
run: echo "Review state changed; default-branch reconciliation will re-evaluate App-owned merge intents."
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with
## Command framework declaration
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
@@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface.
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
+2
View File
@@ -54,6 +54,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
### Changed
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.60-beta.1"
version "1.0.60-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
end
def install
+215 -4
View File
@@ -62,9 +62,171 @@ make lint
git diff --check
```
## Reviewer Router GitHub App
Reviewer requests and merge authority intentionally use different identities.
The base-owned `pull_request_target` workflow may use its built-in
`GITHUB_TOKEN` to request reviewers, but it must mint a dedicated GitHub App
installation token before enabling auto-merge. GitHub suppresses most workflow
events created by the built-in token; using it for auto-merge prevents the
merge commit's `push` workflows from running and leaves the exact-SHA Coverage
baseline without a trusted main-scoped producer.
Configure the dedicated App before merging a workflow revision that requires
it:
- install it only on `DingTalk-Real-AI/dingtalk-workspace-cli`;
- grant only `Contents: read and write` and `Pull requests: read and write`;
- set repository variable `REVIEWER_ROUTER_APP_CLIENT_ID` to its client ID;
- set `REVIEWER_ROUTER_APP_SLUG` to its exact lowercase slug;
- set repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY` to its private key;
- create one active repository branch ruleset named `main-merge-writers`,
targeting only `refs/heads/main`, with exactly one `Restrict updates` rule
(`update_allows_fetch_and_merge: false`). GitHub may project that strict
value through the read APIs as `{type: "update"}` with `parameters` omitted;
consumers accept only that exact omission or a one-field `parameters` object
containing explicit boolean `false`, and reject every other present shape or
value. They then bind the same ruleset node through GraphQL and require its
non-null `updateAllowsFetchAndMerge` value to be exactly `false`;
- give that ruleset exactly three bypass actors: the Reviewer Router App as an
`Integration` in `pull_request` mode, plus `haofeng0705` (ID `30925823`) and
`PeterGuy326` (ID `47820304`) in `always` mode for Formula publication and
break-glass recovery;
- never give the App bypass on `main-protection`, `main-quality`, or any other
ruleset, and never reuse `HOMEBREW_PR_TOKEN`,
`RELEASE_GOVERNANCE_TOKEN`, or a personal token for Reviewer Router.
The workflow limits each minted token to the current repository, requests the
two permissions explicitly, and lets the token action revoke it at job end.
It also requires the minted App slug to equal the reviewed repository variable;
there is no `GITHUB_TOKEN` fallback. Before reading App credentials, the
base-owned workflow revalidates the event's exact base/head and uses its
built-in token only to disable an existing request owned by
`github-actions[bot]` or one whose title or merge metadata requests that GitHub
skip workflows. A mint or permission failure therefore leaves that PR
manual-merge only. The built-in token's `Contents: write` permission is
isolated to this trusted cleanup job and is never used to enable auto-merge;
review routing keeps `Contents: read`. Existing requests owned by a human or
another non-built-in identity are replaced with the exact dedicated-App
request after token minting. Only an already App-owned request with the fixed
headline/body is preserved. The required `Test` context reads the live
repository settings and applied rulesets, verifies the exact writer-rule
shape, and requires its own built-in Actions identity to report
`current_user_can_bypass: never`. Before enabling or reconciling auto-merge,
the minted App independently requires `pull_requests_only` on that writer rule
and `never` on every other active main ruleset. These identity-relative checks
remain available to low-privilege tokens; GitHub deliberately hides the full
`bypass_actors` list from callers without ruleset-write access. Operators must
therefore inspect that list during rollout and keep it at the exact three actors
above. The required `Test` context then briefly waits for the concurrent
router takeover and accepts only a null request or the configured App owner
with exact fixed metadata. A null request is safe for this failure mode because
the built-in Actions identity cannot pass the writer rule; other permitted
identities emit either a protected-main push or the trusted closed-PR repair.
Draft PRs skip this identity check; the explicit `ready_for_review` trigger
reruns admission when they become merge-eligible,
while `edited` and `auto_merge_enabled` rerun both workflows when the PR title
or merge request changes. A human `auto_merge_disabled` event reruns CI without
silently re-enabling the request, leaving it available only to the designated
break-glass identity. The required `Test` context rejects GitHub workflow-skip
directives in the PR title or an existing auto-merge request and verifies the
repository's reviewed `MERGE_MESSAGE` title plus `PR_TITLE` or `BLANK` body
defaults. GitHub does not expose those merge-related settings to the read-only
admission token: the classifier accepts only both exact reviewed values or the
complete omission of both properties, and rejects partial omission, `null`, or
any other value. Before any enable, reconcile, or merge mutation, the dedicated
App's current-repository token (which has `Contents: write`) must observe both
exact reviewed values. The dedicated App binds each mutation to the exact head
OID and supplies a fixed safe headline and body, so GitHub cannot copy an unsafe
PR title into its merge commit.
After enabling, the workflow requires the owner to equal the token action's
exact `<app-slug>[bot]` output. If the event base/head changes during the
mutation window, it removes only that App-owned request and fails the run.
The App-owned native auto-merge request is the reviewed automation intent, not
the sole executor: GitHub's deferred auto-merge path does not reliably apply a
GitHub App's pull-request-only ruleset bypass. A zero-permission approval-signal
workflow converts submitted or dismissed reviews into `workflow_run`; completed
admission workflows use the same trusted default-branch trigger. The serialized
reconcile job treats `workflow_run` only as a wake-up signal: it never reads the
triggering run's pull-request payload or artifacts and never checks out code
from that run. It enumerates open `main` PRs again through the API, then
revalidates the safe App owner, metadata, and ruleset boundary immediately
before calling the synchronous PR merge endpoint with the exact current head
SHA. The preflight requires exactly one repository-owned `main-protection`
ruleset with one latest-head approval and exactly one repository-owned
`main-quality` ruleset with the reviewed nine strict checks. The App must report
`never` on both and on every other non-writer ruleset. Every required context
must be bound to the GitHub Actions App (`integration_id=15368`); a missing,
different, or duplicate context/source entry fails closed together with
deletion or weakening of either gate. HTTP 405 means the PR is not ready,
while 409 means its revision
changed; either remains open for the next event. Other failures make
reconciliation red. A concurrent native merge is accepted only after the final
PR state proves the exact head, App identity, and non-empty merge SHA.
A staggered twice-hourly schedule provides eventual recovery if a webhook or
workflow completion is delayed, and `workflow_dispatch` remains the on-demand
repair path.
The break-glass publisher must preserve a safe final commit message;
`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`,
`[actions skip]`, and a `skip-checks: true` trailer are forbidden outside the
release-controlled Formula-only path below.
GitHub may suppress `pull_request_target` entirely for security-sensitive head
branch names, including names that look like commit SHAs. Such a PR receives
neither App takeover nor the closed-event repair. Rename the head branch for
the normal path; if break-glass merge is unavoidable, preserve a safe final
message so the protected-main push CI remains the authoritative producer.
After installing the App, the protected-main push that deploys this workflow
runs reconciliation automatically. Approval-signal and admission-workflow
completions run the same serialized recovery path. The job enumerates open,
ready `main` PRs
with any non-App owner, unsafe App metadata, or workflow-skip metadata. It
revalidates each base/head, converges a safe request to the exact dedicated-App
owner and fixed message, and leaves a workflow-skipping request disabled for
manual correction. It never enables auto-merge where the request was already
null. Every exact safe App request is then attempted through the synchronous,
SHA-bound merge endpoint; a server-declared not-ready result remains open for
the next event. A mid-migration failure leaves the affected PR disabled for a
fresh routing event or break-glass merge. One PR failure is recorded
without preventing later legacy owners from being attempted; the batch ends
red with a per-PR summary. Manually dispatch `Reviewer routing` from `main`
until the failed count is zero.
Disabling the App-owned auto-merge request before the reconcile job's final PR
read leaves that PR manual-only. That final read is the cancellation
linearization point: GitHub's merge API can condition atomically on the head SHA
but not on the auto-merge request itself, so a disable racing after that read may
lose to an already-issued merge request. To stop an in-flight attempt
before the merge endpoint accepts it, close the PR or change its head; if the
server observes that state first, it rejects the state/SHA-bound merge. No
client-side action can revoke a merge that GitHub has already accepted.
The endpoint has no equivalent expected-base parameter. The workflow therefore
checks `base=main` and the repository before and after merge and fails any
retargeted result, but a retarget racing after the final read cannot be made
atomic client-side. Never retarget a PR while its App-owned intent is active:
disable the request, wait until all running `Reviewer routing` reconciliation
jobs finish, and only then change the base. Preventing a malicious same-instant
retarget requires a GitHub-side branch/ruleset control rather than workflow
code.
A PR that introduces or rotates this identity still runs the old base-owned
router. Install/configure the App and activate the exact writer ruleset first;
this blocks its legacy `github-actions[bot]` request from writing `main`. After
the governance PR's final push, disable that old request, confirm the live
settings/ruleset contract and all required checks are green for the exact head,
then have only `haofeng0705` or `PeterGuy326` merge that head with the
repository-generated safe merge message. Verify the resulting merge SHA has a
`CI` run with `event=push`,
a successful `Coverage` context, and an exact-SHA baseline cache under
`refs/heads/main`. Confirm automatic reconciliation reports zero failures and
zero non-App owners. Finally use a normal canary PR to verify that the dedicated
App is both `enabledBy` and `mergedBy`, and that the same post-merge chain
repeats before declaring the rollout complete.
## Homebrew Formula Delivery
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
Official releases use the designated `HOMEBREW_PR_TOKEN` identity to update
exactly one tracked Formula after the immutable GitHub assets and their
checksums have passed verification. The publisher validates the rendered Ruby,
commits only the configured Formula path, never force-pushes `main`, and retries
@@ -72,11 +234,60 @@ from a fresh clone up to three times when `main` advances concurrently. Normal
stable and beta releases do not create a Formula PR or run a permission
canary. The workflow uses the existing repository-scoped
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
Actions App to bypass this repository's rulesets. That identity is the sole
user bypass actor on the two default-branch rulesets. The workflow creates the
Actions App to bypass this repository's rulesets. Its owner is the designated
always-bypass actor for controlled Formula publication and break-glass recovery,
including on `main-merge-writers`. The workflow creates the
nine Code Admission checks for the Formula-only commit only after proving its
sole parent already has all nine successful checks and the committed Formula
exactly matches this release's verified bytes.
exactly matches this release's verified bytes. Formula commits retain
`[skip ci]`, so the sealing step exposes only the reviewed commit identity to
an independent confirmation job. That job creates the
`Coverage Baseline Cache` acknowledgement and emits the reviewed
`coverage-baseline-promote` repository dispatch. The default-branch
`Coverage Baseline Promotion` workflow independently verifies the exact
single-parent Formula commit, both parent and target admission contexts, and
default-branch containment before checking out the target. It restores only
the exact parent profile, recomputes the complete profile if that cache is
absent, and saves the Formula SHA under the `main` cache scope. Because the
cache save action treats upload errors as warnings, a second lookup must report
`cache-hit=true` for the exact target key before the producer succeeds. The
promotion completes the unique acknowledgement, and the confirmation job
waits for that exact check-run ID. npm and mirror publication depend only on
the immutable release job, so a transient
cache-service failure cannot strand an otherwise valid release between
channels; the final release-delivery gate still fails until the exact cache is
confirmed. Once Formula sealing exposes the target SHA, the confirmation job
also runs when a later immutable-package recheck fails, so a post-push failure
cannot orphan the producer. Rerun the failed promotion/confirmation path after
repairing the producer. Never add a prefix `restore-keys` fallback to this path.
`Coverage Baseline Repair` is the independent safety net for every merged PR.
Its base-owned `pull_request_target: closed` job never checks out or executes PR
content: it binds the closed event's PR number and stable head SHA to the
current merged-PR facts (`merged_at`, `base.ref`, and `merge_commit_sha`) and
proves that merge commit is contained in `main`. It deliberately does not
compare REST `base.sha`, because that field follows the live base branch and
can move after the merge. Only then does it emit a
`coverage-baseline-repair` repository dispatch. Workflow-skip directives alone
do not suppress `pull_request_target`, subject to GitHub's separate
security-sensitive branch-name restriction described above. The low-trust
trigger is forbidden from writing the default-branch cache directly. Before
dispatching, it gives Actions event delivery one minute to expose a run from
the exact protected `.github/workflows/ci.yml` workflow and exits if that normal producer already
owns the SHA, avoiding a duplicate full-suite run. A successful CI producer
must hard-verify its exact cache key. If that run instead completes with any
non-success conclusion, a separate base-owned `workflow_run` dispatcher binds
the exact workflow ID/path, run ID/attempt, conclusion, repository, branch, and
head SHA before requesting repair. `workflow_run` also has read-only
default-branch cache access, so both dispatchers use the reviewed
`repository_dispatch` exception. The dispatched default-branch producer
revalidates the corresponding merged-PR or failed-CI identity before checkout,
restores only the exact target key, recomputes the complete profile on a miss,
and verifies `cache-hit=true` after saving. An hourly schedule refreshes the
event-time `main` SHA after direct break-glass pushes or cache eviction;
`workflow_dispatch` provides the same current-main repair on demand. The
dedicated App identity remains mandatory because events created by the built-in
`GITHUB_TOKEN` can suppress both the main push and the closed-PR event.
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
+187 -10
View File
@@ -147,13 +147,112 @@ maintainer pool. A current-head approval or change request is preserved; after
a new push, stale activity does not suppress a fresh request, and an
outstanding change requester is preferred for continuity.
The branch ruleset keeps one human approval and all nine strict required
contexts, and requires someone other than the latest pusher to approve after
the most recent head update. Repository auto-merge is enabled for ready PRs,
so a PR merges after that approval and the current revision's nine checks are
green. If `main` advances, strict checks rerun before merge. The reviewer
router is orchestration, not a quality context, and must not be added to the
ruleset.
The branch rulesets keep one human approval and all nine strict required
contexts, require someone other than the latest pusher to approve after the
most recent head update, and restrict `main` updates to the dedicated Reviewer
Router App in pull-request mode plus the designated Formula publishers and
break-glass identities. Repository auto-merge is enabled for ready PRs, so the
App-owned request records the automation intent while the App's synchronous
merge path waits for that approval and the current revision's nine green
checks. If `main` advances, strict checks rerun before merge. The
reviewer routing job uses the built-in `GITHUB_TOKEN` to request reviewers with
`Contents: read` and `Pull requests: write`. A separate base-owned cleanup job
isolates the merge-authority permissions (`Contents: write` and `Pull
requests: write`), revalidates the exact event base/head, and uses the built-in
token only to disable an existing request owned by `github-actions[bot]` or one
whose title or merge metadata requests that GitHub skip workflows; it never
enables auto-merge. The job then mints a current-repository installation token
for the dedicated Reviewer Router GitHub App, proves its emitted slug matches
the reviewed `REVIEWER_ROUTER_APP_SLUG`, replaces every non-App request, and
enables native auto-merge with fixed metadata. This
identity boundary is required because GitHub suppresses
most workflow events created by the built-in token; using it for auto-merge would
silently skip the merge commit's protected-main CI and baseline-cache
producer. Token minting or takeover fails closed without falling back to
`GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and
the required `Test` context live-verifies the exact `main-merge-writers` update
rule. GitHub's read APIs may omit `parameters` for the strict
`update_allows_fetch_and_merge: false` value, so the gate accepts only that
exact omission or a one-field `parameters` object containing explicit boolean
`false`; every other present shape or value fails closed. The gate then binds
the same ruleset node through GraphQL and requires its non-null
`updateAllowsFetchAndMerge` value to be exactly `false`. It also requires its
own built-in token to report
`current_user_can_bypass: never`. The minted App separately requires
`pull_requests_only` on that writer rule and `never` on every other active main
ruleset before it can enable, reconcile, or synchronously merge. The read-only
`Test`
token may receive a repository projection with both merge-default properties
omitted; it accepts only that complete omission or exact `MERGE_MESSAGE` plus
`PR_TITLE`/`BLANK`, while partial or malformed projections fail closed.
The same unprivileged `pull_request` job may receive an empty repository-variable
projection for an external fork. Only when the event head repository differs
from the base repository does it substitute the exact reviewed public slug
`dingtalk-dws-reviewer-router` for identity comparison. An empty variable on a
same-repository PR and every malformed non-empty value still fail closed. This
fallback neither mints a token nor grants merge authority; the base-owned
Router continues to require its minted App slug to equal the repository
variable before any mutation. The minted App's `Contents: write` token must
observe the exact reviewed defaults
before either mutation path proceeds. GitHub hides the complete
`bypass_actors` list from low-privilege callers, so the rollout audit must still
keep the writer list at exactly the Reviewer App, `haofeng0705` (ID
`30925823`), and `PeterGuy326` (ID `47820304`). The required check finally
accepts a null or exact App-owned
request after a short takeover grace period. Null is safe from the suppressed
event path because the built-in Actions identity cannot update `main`; other
permitted identities produce either a main push or the trusted closed-PR
repair. Drafts skip the identity step, while `ready_for_review`, `edited`,
`auto_merge_enabled`, and `auto_merge_disabled` explicitly start fresh admission
for readiness, title, and merge-request changes. Router does not react to
`auto_merge_disabled`, so a
human can deliberately leave the PR manual-only for break-glass handling.
Reviewer routing remains available. The protected-main push that deploys the
workflow automatically migrates every open, ready non-App request and repairs
unsafe App metadata; it disables workflow-skipping requests for correction.
Because GitHub's deferred native auto-merge path does not reliably apply an
App's pull-request-only ruleset bypass, a zero-permission approval-signal
workflow and completed `CI` / `Code Admission — AI Behavior` workflows wake the
same trusted default-branch reconciliation through `workflow_run`. That event
is only a wake-up signal: the privileged job does not consume its pull-request
payload or artifacts and does not check out the triggering run's code. It
re-enumerates open `main` PRs through the API and attempts only an exact
App-owned request through the synchronous PR merge endpoint. Immediately before
each attempt it revalidates the App's ruleset boundary and PR intent, supplies
the current head SHA, and treats server-declared not-ready or
concurrent-revision responses as retriable. The live preflight requires the
exact repository-owned approval ruleset and exact nine-check strict quality
ruleset, with every context bound to the GitHub Actions App
(`integration_id=15368`) and the Reviewer Router App unable to bypass either;
a missing, disabled, incorrectly sourced, or weakened gate fails closed before
merge. GitHub—not the workflow—decides whether the
merge is admissible. A staggered twice-hourly schedule provides eventual
recovery, and a manual `workflow_dispatch` from `main` is the immediate
idempotent retry path.
Reconciliation never enables an originally null request. The reviewer router
is orchestration, not a quality context, and
must not be added to the ruleset.
Disabling the App-owned request before the reconcile job's final PR read keeps
the PR manual-only. GitHub can atomically bind the subsequent merge to the head
SHA, but it cannot bind that call to the auto-merge intent; a disable racing
after the final read may therefore lose to the in-flight merge. Closing the PR
or changing its head blocks the attempt only if GitHub observes that state
before accepting the merge endpoint call; no client-side action can revoke a
merge that the server has already accepted.
The merge endpoint has no expected-base precondition. Reconciliation checks
that the base is this repository's `main` immediately before and after the call,
but a retarget racing after the final read is not atomically preventable in the
workflow. Operators must disable the App-owned intent and wait for all running
`Reviewer routing` reconciliation jobs to finish before retargeting a PR; a
stronger adversarial guarantee requires a GitHub-side branch/ruleset control.
GitHub may omit `pull_request_target` for security-sensitive head branch names,
including names that look like commit SHAs. Those PRs cannot use Router App
takeover or the closed-event repair: rename the branch for the supported path,
or use the designated break-glass identity with a safe final message so main
push CI remains the exact-SHA producer.
## Running focused gates locally
@@ -213,7 +312,76 @@ the same dedicated cache profile path because GitHub includes that path in the
cache version; the runtime-facing candidate and baseline filenames remain
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
keys, because a neighbouring commit's profile would compare the candidate
against the wrong baseline. Supporting and (when
against the wrong baseline. PR concurrency is keyed by PR number, so a later
revision cancels the stale run instead of letting obsolete test matrices
compete with the replacement for hosted runners. If cancellation interrupts a
cold-cache fallback, the latest run recomputes the same exact merge-base
profile authoritatively. Main concurrency remains keyed by pushed SHA, so a
newer main push cannot cancel a predecessor's producer.
Every supported main advancement path has an exact-SHA producer. The required
`Test` context rejects GitHub workflow-skip directives in PR and auto-merge
metadata, reruns when that metadata is enabled, disabled, or edited, and
verifies the live App/writer-ruleset identity contract. Reviewer Router
additionally binds auto-merge to the exact head OID and writes a fixed safe
merge headline/body. The sole break-glass publisher must retain a safe final
message; the release-controlled Formula-only path
is the sole supported use of `[skip ci]`. A full source push
saves the assembled profile after the aggregate gate passes. A trusted
documentation or release-seal push independently verifies that the complete
`before...after` diff contains only the reviewed metadata allowlist, restores
only the exact `before` cache, recomputes the full profile if the chain is
cold, and makes that helper a dependency of the required `Coverage` context.
Release-generated Formula commits intentionally retain `[skip ci]`; after
their nine synthetic contexts are sealed, an independent release-governance
job creates an acknowledgement and emits a `coverage-baseline-promote`
repository dispatch. The default-branch promotion
workflow revalidates the exact single-parent Formula identity, successful
parent and target contexts, and main containment before it promotes the exact
parent cache or performs the same full fallback. Every target-main producer
follows its save with a lookup-only restore and requires
`cache-hit=true` for the exact key; this turns the cache action's otherwise
warning-only upload failure or prefix match into a hard failure. Formula
promotion additionally updates one release-created `Coverage Baseline Cache`
check. A separate confirmation job waits for that exact check-run ID while npm
and mirrors remain dependent only on the immutable publication job; cache
failure therefore makes the final delivery gate red without creating a
partially published release. Once Formula sealing exposes its SHA, a later
publication verification failure cannot suppress that confirmation job.
A separate base-owned `pull_request_target: closed` safety net covers the final
merged SHA even if a human or integration changes the merge message after PR
checks finish. Skip directives alone do not suppress `pull_request_target`,
subject to GitHub's separate security-sensitive branch-name restriction above.
That job executes no PR code and only dispatches after binding the exact
closed-event PR number and stable head SHA to merged-PR facts
(`merged_at`, `base.ref`, and `merge_commit_sha`) and proving `main`
containment. It does not compare the later REST `base.sha`, which follows the
live base branch after merge. Because GitHub makes default-branch caches
read-only to `pull_request_target`, the dispatcher first waits up to one minute
for a run from the exact protected
`.github/workflows/ci.yml` workflow and exits when that normal producer exists.
A successful main CI hard-verifies the exact key itself. A completed
non-success run starts a separate base-owned `workflow_run` dispatcher, which
binds the exact CI workflow ID/path, run ID/attempt, conclusion, upstream
repository, `main` branch, and head SHA. That trigger is also cache-read-only,
so either trusted dispatcher uses `repository_dispatch`; its producer
revalidates the merged-PR or failed-CI identity, checks out the contained SHA,
and produces/verifies the exact full cache.
An hourly schedule and a main-only manual dispatch repair the event-time main
SHA after a direct break-glass push or cache eviction. The dispatch exception
is intentional: unlike an ordinary event created by `GITHUB_TOKEN`, GitHub
allows `repository_dispatch` to start another workflow. A legacy built-in-token
merge can suppress the closed event too, which is why the required `Test`
identity gate and dedicated Reviewer Router App are still mandatory.
A cold miss can still occur during a producer race or after cache eviction,
but it remains fail-safe: the PR recomputes the authoritative baseline with a
30-minute job budget and saves a PR-scoped copy for same-PR reruns. It is no
longer possible for a supported main-advance path to omit its producer
silently. That PR-scoped fallback save remains a best-effort acceleration and
does not replace the normal push, metadata, Formula, and merged-PR repair
producers. Supporting and (when
platform-selected) native profiles are generated before the aggregate
`Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
@@ -246,7 +414,9 @@ tool、parameter、mapping、positional execution、constraint 与 safety 语义
The `main` quality ruleset must enable strict required-status-check policy
(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever
`main` advances. It must require these exact contexts and no legacy aliases:
`main` advances. Every entry must select the GitHub Actions App
(`integration_id=15368`), not “any source”. It must require these exact
context/source pairs and no legacy aliases:
- `Lint`
- `Test`
@@ -265,4 +435,11 @@ unproducible required context.
The branch ruleset also requires one approval after the latest push. Enable
repository auto-merge and automatic head-branch deletion; keep the base-owned
reviewer router outside the required-context list.
reviewer router outside the required-context list. Install its dedicated
GitHub App only on this repository with `Contents: read and write` and `Pull
requests: read and write`; do not grant Actions, Workflows, or Administration.
Give it pull-request-only bypass on `main-merge-writers` and no bypass on any
other ruleset. Store the App client ID and lowercase slug in repository
variables `REVIEWER_ROUTER_APP_CLIENT_ID` and `REVIEWER_ROUTER_APP_SLUG`, and
its private key in repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY`. Do not
reuse release, Homebrew, or personal tokens for this boundary.
+20 -1
View File
@@ -274,6 +274,7 @@ Definition(仅声明;不可编译)
| 层 | 含义 | 今日落点 |
|---|---|---|
| **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) |
| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 |
| **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) |
| **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) |
| **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) |
@@ -310,7 +311,25 @@ Definition(仅声明;不可编译)
3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格;
4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。
#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3)
#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`)
`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`:
| 轴 | 允许值 | 语义 |
|---|---|---|
| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 |
| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 |
| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 |
硬规则:
1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。
2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。
3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。
4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。
5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。
#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3)
当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定):
+209 -42
View File
@@ -1,7 +1,57 @@
{
"generated_at": "2026-08-24T12:22:05.108140",
"count": 426,
"generated_at": "2026-08-24T20:14:49.172788",
"count": 437,
"results": [
{
"suite": "semantic",
"service": "agoal",
"command": "+contract-fields",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+obj-template-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+report-statistics-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+report-submit-detail",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。",
"availability": "available"
},
{
"suite": "semantic",
"service": "agoal",
"command": "+user-rules",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aisearch",
@@ -2473,137 +2523,254 @@
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+create",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+credentials-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+delete",
"risk": "high-risk-write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+disable",
"risk": "write",
"status": "real-ok"
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+enable",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+event-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+event-subscribe",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+member-add",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+member-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+member-remove",
"risk": "write",
"status": "real-ok"
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+permission-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+robot-config",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+robot-disable",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+robot-enable",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+robot-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+update",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-check-approval",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。",
"availability": "available"
},
{
"suite": "semantic",
"service": "devapp",
"command": "+version-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+version-status",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "devapp",
"command": "+webapp-config",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "devapp",
"command": "+webapp-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。",
"availability": "available"
},
{
"suite": "semantic",
+1 -1
View File
@@ -380,7 +380,7 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
if normalizeDirectRuntimeProductID("alias") != "one" || normalizeDirectRuntimeProductID("tb") != "teambition" || normalizeDirectRuntimeProductID("plain") != "plain" {
t.Fatal("direct runtime alias mismatch")
}
if ids := DirectRuntimeProductIDs(); !ids["one"] || !ids[defaultPATProductID] || !ids[devappProductID] {
if ids := DirectRuntimeProductIDs(); !ids["one"] || !ids[defaultPATProductID] || !ids[devappProductID] || !ids[recruitProductID] {
t.Fatalf("direct runtime IDs = %#v", ids)
}
+3 -1
View File
@@ -46,6 +46,7 @@ const (
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
devappProductID = "devapp"
devappServerPath = "/server/op-app"
recruitProductID = "recruit"
)
// devappMCPEndpoint resolves the open-platform app-management MCP endpoint
@@ -400,9 +401,10 @@ func DirectRuntimeProductIDs() map[string]bool {
dynamicMu.RLock()
defer dynamicMu.RUnlock()
ids := make(map[string]bool, len(dynamicProducts)+2)
ids := make(map[string]bool, len(dynamicProducts)+3)
ids[defaultPATProductID] = true
ids[devappProductID] = true
ids[recruitProductID] = true
for key := range dynamicProducts {
ids[key] = true
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the
// old helpers-only AST scan with an invariant over the two real assembly
// products: the deterministic distribution tree and a runtime tree after a
// nested plugin overlay has been merged.
func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) {
distribution := NewSchemaSourceRootCommand()
for _, path := range []string{
"sheet range read",
"pat chmod",
"plugin list",
"chat +chat-messages",
} {
requireFinalCommandPath(t, distribution, path)
}
if err := cobracmd.ValidateGroupTree(distribution); err != nil {
t.Fatalf("distribution command tree GroupPolicy invariant: %v", err)
}
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command {
descriptor := conferencePluginDescriptor()
return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root)
})
runtime := NewRootCommand()
requireFinalCommandPath(t, runtime, "conference camera open")
if err := cobracmd.ValidateGroupTree(runtime); err != nil {
t.Fatalf("runtime command tree GroupPolicy invariant: %v", err)
}
}
func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command {
t.Helper()
command, remaining, err := root.Find(strings.Fields(path))
if err != nil || command == nil || len(remaining) != 0 || command == root {
t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err)
}
return command
}
+6
View File
@@ -21,6 +21,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
@@ -43,6 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
return cmd.Help()
},
}
corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
group.AddCommand(newMCPURLGetCommand(caller))
return group
}
@@ -237,11 +237,25 @@ var paramAliasCompleteCommands = map[string][]string{
// param_concepts.json: inactive candidate templates are ignored, while every
// command becomes mandatory as soon as one of its reviewed aliases is active.
var paramAliasCandidateCompleteCommands = map[string][]string{
"agoal contract detail": {"agoal", "contract", "detail", "--contract-id", "contract-1"},
"agoal contract update": {"agoal", "contract", "update", "--contract-id", "contract-1", "--dimensions", `[{"id":"dimension-1","title":"Fixture Dimension","weight":100,"objectives":[]}]`},
"agoal obj-template create-or-update": {"agoal", "obj-template", "create-or-update", "--template-id", "template-1", "--dimensions", `[{"title":"Fixture Dimension","weight":100}]`},
"agoal obj-template list": {"agoal", "obj-template", "list", "--keyword", "fixture", "--page", "2", "--page-size", "7"},
"agoal report list-statistics": {"agoal", "report", "list-statistics", "--keyword", "Fixture Rule"},
"agoal report submit-detail": {"agoal", "report", "submit-detail", "--template-id", "template-1", "--submit-state", "ON_TIME", "--query-date", "2026-06-18T00:00:00+08:00"},
"agoal scorecard detail": {"agoal", "scorecard", "detail", "--dept-id", "dept-1", "--selected-time", "2026-01-01T00:00:00+08:00"},
"agoal scorecard entity-detail": {"agoal", "scorecard", "entity-detail", "--sc-id", "scorecard-1", "--entity-id", "entity-1"},
"agoal strategy detail": {"agoal", "strategy", "detail", "--profile-id", "profile-1"},
"agoal user objectives": {"agoal", "user", "objectives", "--user-id", "user-1", "--rule-id", "rule-1", "--period-ids", "period-1,period-2"},
"agoal user rules": {"agoal", "user", "rules", "--user-id", "user-1"},
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
"audit export": {"audit", "export", "--since", "2026-03-01", "--until", "2026-03-10", "--format", "jsonl", "--output", "/tmp/dws-audit-export-fixture.jsonl"},
"audit tail": {"audit", "tail", "--lines", "7", "--output", "/tmp/dws-audit-tail-fixture.jsonl"},
"audit verify": {"audit", "verify", "--file", "../../go.mod", "--output", "/tmp/dws-audit-verify-fixture.json"},
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
@@ -267,7 +281,66 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
"dev app create": {"dev", "app", "create", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
"dev app credentials get": {"dev", "app", "credentials", "get", "--unified-app-id", "app-1"},
"dev app delete": {"dev", "app", "delete", "--unified-app-id", "app-1", "--confirm-name", "Fixture App", "--yes"},
"dev app disable": {"dev", "app", "disable", "--unified-app-id", "app-1", "--yes"},
"dev app enable": {"dev", "app", "enable", "--unified-app-id", "app-1", "--yes"},
"dev app event list": {"dev", "app", "event", "list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
"dev app event subscribe": {"dev", "app", "event", "subscribe", "--unified-app-id", "app-1", "--event-codes", "chat_message_received", "--yes"},
"dev app event unsubscribe": {"dev", "app", "event", "unsubscribe", "--unified-app-id", "app-1", "--event-codes", "chat_message_received", "--yes"},
"dev app list": {"dev", "app", "list", "--robot-name", "Fixture Robot"},
"dev app member add": {"dev", "app", "member", "add", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
"dev app member list": {"dev", "app", "member", "list", "--unified-app-id", "app-1"},
"dev app member remove": {"dev", "app", "member", "remove", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
"dev app permission add": {"dev", "app", "permission", "add", "--unified-app-id", "app-1", "--scope-values", "Contact.User.Read", "--yes"},
"dev app permission remove": {"dev", "app", "permission", "remove", "--unified-app-id", "app-1", "--scope-values", "Contact.User.Read", "--yes"},
"dev app robot config": {"dev", "app", "robot", "config", "--unified-app-id", "app-1", "--i18n-description", `{"zh_CN":"Fixture Robot"}`, "--yes"},
"dev app robot disable": {"dev", "app", "robot", "disable", "--unified-app-id", "app-1", "--yes"},
"dev app robot enable": {"dev", "app", "robot", "enable", "--unified-app-id", "app-1", "--yes"},
"dev app robot get": {"dev", "app", "robot", "get", "--unified-app-id", "app-1"},
"dev app robot result": {"dev", "app", "robot", "result", "--task-id", "task-1"},
"dev app robot submit": {"dev", "app", "robot", "submit", "--name", "Fixture Agent", "--desc", "Fixture robot description", "--robot-name", "Fixture Robot", "--yes"},
"dev app security config": {"dev", "app", "security", "config", "--unified-app-id", "app-1", "--redirect-urls", "https://example.test/callback", "--yes"},
"dev app update": {"dev", "app", "update", "--unified-app-id", "app-1", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
"dev app version check-approval": {"dev", "app", "version", "check-approval", "--unified-app-id", "app-1", "--version-id", "version-1"},
"dev app version create": {"dev", "app", "version", "create", "--unified-app-id", "app-1", "--version", "1.0.1", "--desc", "Fixture Version", "--yes"},
"dev app version get": {"dev", "app", "version", "get", "--unified-app-id", "app-1", "--version-id", "version-1"},
"dev app version list": {"dev", "app", "version", "list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
"dev app version publish": {"dev", "app", "version", "publish", "--unified-app-id", "app-1", "--version-id", "version-1", "--yes"},
"dev app version status": {"dev", "app", "version", "status", "--unified-app-id", "app-1", "--version-id", "version-1"},
"dev app webapp config": {"dev", "app", "webapp", "config", "--unified-app-id", "app-1", "--pc-homepage-url", "https://example.test/app", "--yes"},
"dev app webapp get": {"dev", "app", "webapp", "get", "--unified-app-id", "app-1"},
"dev connect restart": {"dev", "connect", "restart", "--robot-client-id", "robot-client-1"},
"dev connect status": {"dev", "connect", "status", "--robot-client-id", "robot-client-1"},
"dev connect stop": {"dev", "connect", "stop", "--robot-client-id", "robot-client-1"},
"dev doc search": {"dev", "doc", "search", "--query", "fixture", "--page", "2"},
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
"devapp +create": {"devapp", "+create", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
"devapp +delete": {"devapp", "+delete", "--unified-app-id", "app-1", "--yes"},
"devapp +disable": {"devapp", "+disable", "--unified-app-id", "app-1", "--yes"},
"devapp +enable": {"devapp", "+enable", "--unified-app-id", "app-1", "--yes"},
"devapp +event-list": {"devapp", "+event-list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
"devapp +get": {"devapp", "+get", "--unified-app-id", "app-1"},
"devapp +list": {"devapp", "+list", "--app-key", "app-key-1"},
"devapp +member-add": {"devapp", "+member-add", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
"devapp +member-list": {"devapp", "+member-list", "--unified-app-id", "app-1", "--user-id", "user-1"},
"devapp +member-remove": {"devapp", "+member-remove", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
"devapp +permission-list": {"devapp", "+permission-list", "--unified-app-id", "app-1", "--api-status", "PUBLISHED", "--scope-type", "APP"},
"devapp +robot-get": {"devapp", "+robot-get", "--unified-app-id", "app-1"},
"devapp +update": {"devapp", "+update", "--unified-app-id", "app-1", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
"devapp +version-check-approval": {"devapp", "+version-check-approval", "--unified-app-id", "app-1", "--version-id", "version-1"},
"devapp +version-get": {"devapp", "+version-get", "--unified-app-id", "app-1", "--version-id", "version-1"},
"devapp +version-list": {"devapp", "+version-list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
"devapp +version-status": {"devapp", "+version-status", "--unified-app-id", "app-1", "--version-id", "version-1"},
"devapp +webapp-config": {"devapp", "+webapp-config", "--unified-app-id", "app-1", "--pc-homepage-url", "https://example.test/app", "--yes"},
"devapp +webapp-get": {"devapp", "+webapp-get", "--unified-app-id", "app-1"},
"event +listen-im": {"event", "+listen-im", "--user", "user-1", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
"event consume": {"event", "consume", "--subscribe-id", "subscription-1", "--user", "user-1", "--group", "fixture-conversation", "--query", "fixture", "--output-dir", "/tmp/dws-event-fixture", "--filter-json", `{"rules":[]}`},
"event list": {"event", "list", "--category", "im", "--include-pending"},
"event schema": {"event", "schema", "--flatten"},
"event status": {"event", "status", "--event", "im_message_received", "--status", "active", "--subscribe-id", "subscription-1"},
"event stop": {"event", "stop", "--all", "--yes"},
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
@@ -335,6 +408,9 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
"report +template-search": {"report", "+template-search", "--query", "fixture"},
"recruit job create": {"recruit", "job", "create", "--from", "testdata/recruit_job.json", "--yes"},
"recruit job get": {"recruit", "job", "get", "--job-id", "job-1"},
"recruit job list": {"recruit", "job", "list", "--job-ids", "job-1,job-2", "--creator-user-ids", "user-1,user-2", "--keyword", "fixture", "--cursor", "cursor-1", "--size", "7"},
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
@@ -397,6 +473,18 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"dev app get": {
"app-key": {"dev", "app", "get", "--app-key", "app-key-1"},
},
"event +listen-im": {
"open-dingtalk-id": {"event", "+listen-im", "--open-dingtalk-id", appFixtureCurrentDOpenID, "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
"user-query": {"event", "+listen-im", "--user-query", "Fixture User", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
"chat-id": {"event", "+listen-im", "--chat-id", "fixture-conversation", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
"chat-query": {"event", "+listen-im", "--chat-query", "Fixture Group", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
},
"event consume": {
"open-dingtalk-id": {"event", "consume", "--subscribe-id", "subscription-1", "--open-dingtalk-id", appFixtureCurrentDOpenID, "--group", "fixture-conversation", "--query", "fixture", "--output-dir", "/tmp/dws-event-fixture", "--filter-json", `{"rules":[]}`},
},
"markdown create": {
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
},
@@ -1106,7 +1194,8 @@ func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassCo
product, _, _ := strings.Cut(fixture.Command, " ")
switch product {
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat",
"agoal", "audit", "dev", "devapp", "event", "mcp", "recruit":
default:
continue
}
@@ -1502,7 +1591,7 @@ func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
case "chat +messages-resource-download":
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
case "drive +download", "drive +version-download":
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
return strings.Contains(err.Error(), "下载地址必须是合法的 HTTPS URL")
case "drive +upload":
return strings.Contains(err.Error(), "incomplete drive upload credentials")
default:
+6 -1
View File
@@ -24,6 +24,7 @@ import (
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
@@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) {
}
for _, child := range append([]*cobra.Command(nil), parent.Commands()...) {
pruneEmptyPluginGroups(child)
if cmdutil.IsGroup(child) && len(child.Commands()) == 0 {
_, group, err := corecmd.GroupPolicyFor(child)
if err != nil {
panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err))
}
if group && len(child.Commands()) == 0 {
parent.RemoveCommand(child)
}
}
+39 -1
View File
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) {
emptyParent := &cobra.Command{Use: "plugin"}
emptyGroup := &cobra.Command{Use: "empty"}
corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
emptyParent.AddCommand(emptyGroup)
pruneEmptyPluginGroups(emptyParent)
if len(emptyParent.Commands()) != 0 {
t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands())
}
parent := &cobra.Command{Use: "plugin"}
child := &cobra.Command{Use: "group"}
corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
for key := range child.Annotations {
child.Annotations[key] = "malformed"
}
parent.AddCommand(child)
defer func() {
got := recover()
message, ok := got.(string)
if !ok || !strings.Contains(message, "prune plugin group") {
t.Fatalf("pruneEmptyPluginGroups panic = %v", got)
}
}()
pruneEmptyPluginGroups(parent)
}
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
@@ -507,7 +544,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source := cobracmd.NewGroupCommand("plugin", "plugin")
source.Aliases = []string{"one", "two"}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
@@ -17,6 +17,7 @@ import (
"bytes"
stderrors "errors"
"io"
"slices"
"strings"
"testing"
@@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
}
}
func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{
"aisearch", "--query", "Alice", "--yess", "enterprise",
"--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help",
}
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
}
if ctx == nil || ctx.Command != "dws aisearch enterprise" ||
!slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") {
t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx)
}
if err := root.Execute(); err != nil {
t.Fatalf("corrected group-middle persistent flag failed: %v", err)
}
}
func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) {
for _, args := range [][]string{
{"aisearch", "--types", "enterprise"},
{"aisearch", "--types", "false", "enterprise"},
{"aisearch", "--types=false", "enterprise"},
} {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
}
if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) {
t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx)
}
}
}
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
@@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
args []string
wantReason string
wantCommand string
wantHint string
}{
{
name: "unknown shortcut",
@@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
{
name: "unknown aisearch subcommand before protected flag",
args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws aisearch",
wantHint: "dws aisearch enterprise",
},
}
for _, test := range tests {
@@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) {
t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
+6 -2
View File
@@ -23,6 +23,7 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
@@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" ||
structured.Message != `unknown subcommand "switch" for "dws auth"` ||
structured.Hint != "Run 'dws auth --help' for the full list" {
t.Fatalf("error = %#v, want bounded auth subcommand guidance", err)
}
}
@@ -0,0 +1,48 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
)
func TestRetiredEduVendorExtensionsAreAbsentFromRuntimeAndSchema(t *testing.T) {
products := []string{
"college-contact",
"edu-app",
"edu-contact",
"edu-familygroup",
"edu-group",
}
root := NewRootCommand()
for _, product := range products {
for _, command := range root.Commands() {
if command.Name() == product {
t.Fatalf("retired product command %q remains mounted", product)
}
}
}
retiredProducts := make(map[string]bool, len(products))
for _, product := range products {
retiredProducts[product] = true
}
snapshot := fullSchemaSnapshotForTest(t)
for _, product := range snapshot.Catalog["products"].([]map[string]any) {
productID, _ := product["id"].(string)
if retiredProducts[productID] {
t.Errorf("retired product %q remains in the Schema catalog", productID)
}
}
for canonicalPath := range snapshot.Tools {
for product := range retiredProducts {
if canonicalPath == product || strings.HasPrefix(canonicalPath, product+".") {
t.Errorf("retired Schema tool %q remains under product %q", canonicalPath, product)
}
}
}
}
+34 -11
View File
@@ -30,6 +30,7 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -254,7 +255,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
}
var publicationErr *outputPublicationError
if err == nil || !stderrors.As(err, &publicationErr) {
err = interrupted
err = interrupted.withCancellationDetail(err)
}
}
if err != nil {
@@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
return nil
},
}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
bindPersistentFlags(root, flags)
@@ -935,25 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
navigationGroup := func(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
hybridGroup := func(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
navigationGroup(newAuthCommand(patCaller)),
navigationGroup(newProfileCommand()),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
navigationGroup(newSkillCommand()),
hybridGroup(newCacheCommand()),
newCatalogCommand(),
newConfigCommand(),
navigationGroup(newConfigCommand()),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(flags),
newAuditCommand(),
hybridGroup(newRecoveryCommand()),
navigationGroup(newEventCommand(flags)),
navigationGroup(newAuditCommand()),
newCompletionCommand(root),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
usage.NewShortcutCommand(),
navigationGroup(usage.NewShortcutCommand()),
schemaCmd,
mcpCmd,
navigationGroup(mcpCmd),
}
root.AddCommand(utilityCommands...)
+4 -2
View File
@@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint)
}
}
+2
View File
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
invocation.Tool,
diag,
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 425
publicShortcutCount = 436
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including reviewed hidden compatibility and unavailable contracts.
schemaPublishedShortcutCount = 482
schemaPublishedShortcutCount = 493
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 425
publiclyDeliveredShortcutCount = 436
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -485,7 +485,7 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
t.Fatalf("confirmation = %q, want %q", got, want)
}
parameters := schemaContractMap(leaf["parameters"])
if got, want := len(parameters), 11; got != want {
if got, want := len(parameters), 13; got != want {
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
}
if required, _ := parameters["node"]["required"].(bool); !required {
@@ -496,7 +496,7 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
}
wantProperties := map[string]string{
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
"block-id": "blockId", "after-block-id": "afterBlockId", "before-block-id": "beforeBlockId", "heading-level": "headingLevel", "old": "old", "new": "new",
"expected-revision": "expectedRevision",
}
for name, want := range wantProperties {
@@ -504,7 +504,7 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
t.Errorf("--%s property = %q, want %q", name, got, want)
}
}
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
for _, name := range []string{"content", "block-id", "after-block-id", "before-block-id", "heading-level", "old", "new"} {
parameter := parameters[name]
if required, _ := parameter["required"].(bool); required {
t.Errorf("--%s required = true, want runtime custom validation", name)
@@ -513,6 +513,9 @@ func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testin
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
}
}
if got, want := schemaContractStringSlice(parameters["command"]["enum"]), []string{"append", "overwrite", "block_insert_before", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}; !schemaContractJSONEqual(got, want) {
t.Errorf("--command enum = %#v, want %#v", got, want)
}
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
}
+47 -8
View File
@@ -20,18 +20,50 @@ import (
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
message string
reason string
origin string
stage string
hint string
actions []string
operation string
retryable *bool
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
combined := text + " " + detail
if code == "999" &&
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
classified := serverFailureClass{
message: message,
reason: "upstream_internal_error",
origin: "dingtalk_api",
stage: "upstream_execution",
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
actions: []string{
"检查目标资源的当前状态,确认本次操作是否已经生效",
"状态未确认前不要直接重试写操作",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
},
}
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
retryable := false
classified.operation = "todo/create_personal_todo"
classified.retryable = &retryable
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
classified.actions = []string{
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
"确认没有创建成功后再重新提交",
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
}
}
return classified, true
}
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
fallbackReason string,
fallbackHint string,
serverKey string,
tool string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
apperrors.WithServerDiag(diag),
}
if classified, ok := classifyServerFailure(message, diag); ok {
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
if classified.operation != "" {
opts = append(opts, apperrors.WithOperation(classified.operation))
}
if classified.retryable != nil {
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
}
}
return apperrors.NewAPI(message, opts...)
}
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
"business_error",
"check parameters",
"chat",
"send_message",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
}
}
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
serverSaysRetryable := true
err := newServerFailureAPIError(
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
"business_error",
"The API returned a business-level error. Check required parameters and values.",
"todo",
"create_personal_todo",
apperrors.ServerDiagnostics{
TraceID: "trace-todo-create",
ServerErrorCode: "999",
ServerRetryable: &serverSaysRetryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.Operation != "todo/create_personal_todo" {
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
}
if !typed.RetryableSet || typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
}
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
t.Fatalf("hint = %q", typed.Hint)
}
for _, action := range typed.Actions {
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
t.Fatalf("misleading action = %q", action)
}
}
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "upstream_internal_error",
"origin": "dingtalk_api",
"stage": "upstream_execution",
"retryable": false,
"trace_id": "trace-todo-create",
"server_error_code": "999",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
"business_error",
"check parameters",
"im",
"list_conversations",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "999",
"trace_id": "trace-todo-replay",
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "todo",
Tool: "create_personal_todo",
CanonicalPath: "todo.create_personal_todo",
Params: map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": "fixture",
"executorIds": []string{"user-1"},
},
},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
}
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
}
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
}
}
+24 -1
View File
@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
@@ -44,14 +45,36 @@ func interruptionExitCode(sig os.Signal) int {
type processInterruption struct {
signal os.Signal
detail error
}
func (e *processInterruption) Error() string {
return fmt.Sprintf("process interrupted by %s", e.signal)
message := fmt.Sprintf("process interrupted by %s", e.signal)
if e.detail != nil {
return fmt.Sprintf("%s: %v", message, e.detail)
}
return message
}
func (e *processInterruption) Unwrap() error { return context.Canceled }
// withCancellationDetail keeps the signal as the primary process error while
// retaining actionable context from a command that stopped because of that
// signal. Plain context cancellation and nested signal errors add no useful
// detail, and unrelated command failures must not be relabelled as part of the
// interruption. The detail is deliberately not exposed through Unwrap so an
// inner structured error cannot override the signal's exit code or subtype.
func (e *processInterruption) withCancellationDetail(err error) *processInterruption {
if e == nil || err == nil || err == context.Canceled || !errors.Is(err, context.Canceled) {
return e
}
var interrupted *processInterruption
if errors.As(err, &interrupted) {
return e
}
return &processInterruption{signal: e.signal, detail: err}
}
func (e *processInterruption) ExitCode() int {
return interruptionExitCode(e.signal)
}
+92
View File
@@ -14,6 +14,7 @@ import (
"syscall"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
@@ -56,6 +57,20 @@ func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
if !errors.Is(interrupted, context.Canceled) || interrupted.ExitCode() != 130 || interrupted.Subtype() != "cancelled_by_user" || !strings.Contains(interrupted.Error(), "interrupt") {
t.Fatalf("interruption=%v", interrupted)
}
detailed := interrupted.withCancellationDetail(fmt.Errorf("resume with dws doc import get: %w", context.Canceled))
if detailed == interrupted || !errors.Is(detailed, context.Canceled) || !strings.Contains(detailed.Error(), "dws doc import get") {
t.Fatalf("detailed interruption=%v", detailed)
}
typedDetail := interrupted.withCancellationDetail(apperrors.NewInternal("resume import", apperrors.WithCause(context.Canceled)))
if code := apperrors.ExitCode(typedDetail); code != 130 {
t.Fatalf("typed cancellation detail changed interruption exit code to %d", code)
}
if got := interrupted.withCancellationDetail(context.Canceled); got != interrupted {
t.Fatalf("plain cancellation changed interruption: %v", got)
}
if got := interrupted.withCancellationDetail(errors.New("unrelated failure")); got != interrupted {
t.Fatalf("unrelated failure changed interruption: %v", got)
}
terminated := &processInterruption{signal: syscall.SIGTERM}
if terminated.ExitCode() != 143 || terminated.Subtype() != "terminated" {
t.Fatalf("termination=%v", terminated)
@@ -66,6 +81,13 @@ func TestFrameworkSignalRedeliveryFallbackAndInterruptionMethods(t *testing.T) {
}
}
func TestCrossPlatformCoverageProcessInterruptionRejectsNestedDetail(t *testing.T) {
interrupted := &processInterruption{signal: syscall.SIGINT}
if got := interrupted.withCancellationDetail(&processInterruption{signal: syscall.SIGTERM}); got != interrupted {
t.Fatalf("nested interruption changed the primary signal error: %v", got)
}
}
func TestFrameworkManageProcessSignalsNilAndEscalation(t *testing.T) {
signals := make(chan os.Signal, 3)
stopped, escalated := false, make(chan os.Signal, 1)
@@ -139,6 +161,76 @@ func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testin
}
}
func TestCrossPlatformCoverageExecuteSignalPreservesCancellationRecoveryCommand(t *testing.T) {
const recoveryCommand = "dws doc import get --task-id task-1 --workspace my-space"
if mode := os.Getenv("DWS_SIGNAL_RECOVERY_HELPER"); mode != "" {
installSignalExecuteSeams(t, mode == "json", os.Stdout, os.Stderr)
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
_, _ = fmt.Fprintln(os.Stderr, "READY")
<-cmd.Context().Done()
return cmd, fmt.Errorf("导入轮询被取消: %w;任务已经提交,可使用 %s 继续查询", cmd.Context().Err(), recoveryCommand)
})
os.Exit(Execute())
}
for _, tc := range []struct {
mode string
}{
{mode: "human"},
{mode: "json"},
} {
t.Run(tc.mode, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageExecuteSignalPreservesCancellationRecoveryCommand$")
cmd.Env = append(os.Environ(), "DWS_SIGNAL_RECOVERY_HELPER="+tc.mode)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
stderrReader := bufio.NewReader(stderr)
ready, err := stderrReader.ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "READY" {
t.Fatalf("helper readiness failed: %q, err=%v", ready, err)
}
if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
t.Skipf("current platform does not support subprocess signal delivery: %v", err)
}
stdoutPayload, stdoutErr := io.ReadAll(stdout)
stderrPayload, stderrErr := io.ReadAll(stderrReader)
if stdoutErr != nil || stderrErr != nil {
t.Fatalf("read helper output: stdout=%v stderr=%v", stdoutErr, stderrErr)
}
waitErr := cmd.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) || exitErr.ExitCode() != 130 {
t.Fatalf("wait error=%v, want exit 130", waitErr)
}
if tc.mode == "json" {
var env output.Envelope
if err := json.Unmarshal(stdoutPayload, &env); err != nil {
t.Fatalf("decode envelope: %v; output=%q", err, stdoutPayload)
}
if env.Error == nil || env.Error.Type != "internal" || env.Error.Subtype != "cancelled_by_user" || env.Error.ExitCode != 130 || !strings.Contains(env.Error.Message, "process interrupted by interrupt") || !strings.Contains(env.Error.Message, recoveryCommand) {
t.Fatalf("error=%+v, want cancellation with recovery command", env.Error)
}
return
}
if !strings.Contains(string(stderrPayload), "process interrupted by interrupt") || !strings.Contains(string(stderrPayload), recoveryCommand) {
t.Fatalf("stderr=%q, want recovery command", stderrPayload)
}
})
}
}
func TestExecuteSignalLegacyExitCodes(t *testing.T) {
for _, tc := range []struct {
signal syscall.Signal
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Fixture Java Engineer",
"description": "Fixture backend development role",
"jobNature": "FULL-TIME",
"requiredEdu": 6,
"minSalary": 20000,
"maxSalary": 35000,
"creatorUserId": "creator-user-id",
"ownerUserIds": [
"owner-user-id-1",
"owner-user-id-2"
],
"extData": {
"headCount": 1,
"fullTimeExtData": {
"salaryMonth": 12,
"minJobExperience": 1,
"maxJobExperience": 3
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -402,7 +402,9 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.get_document_content --start-block-id": "Runtime extension sends startBlockId for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_content --tags": "Runtime extension sends tags for scoped JSONML reads, which is absent from the immutable pinned get_document_content metadata at its declared source revision.",
"doc.get_document_style --node": "Reviewed unpinned adapter: doc.get_document_style has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"doc.import_get --folder": "Composite recovery verification input; it is used only by the get_document_info readback after query_import_task completes.",
"doc.import_get --task-id": "Reviewed unpinned adapter: doc.import_get has no singular pinned interface_ref; --task-id is a CLI wrapper input and does not publish a direct interface property.",
"doc.import_get --workspace": "Composite recovery verification input; it is used only by the get_document_info readback after query_import_task completes.",
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
"doc.insert_document_block --level": "aggregate convenience input used to build element",
+139 -9
View File
@@ -16,9 +16,10 @@
package cobracmd
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return cmd
}
@@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) {
if dst == nil || src == nil {
return
}
mergeGroupPolicy(dst, src)
if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) {
dst.Short = src.Short
}
@@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) {
}
}
func mergeGroupPolicy(dst, src *cobra.Command) {
dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst)
if err != nil {
panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err))
}
srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src)
if err != nil {
panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err))
}
if len(dst.Commands()) > 0 && !dstOK {
panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath()))
}
if len(src.Commands()) > 0 && !srcOK {
panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath()))
}
if dstOK && !srcOK {
if !isNeutralMergePlaceholder(src) {
panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q",
src.CommandPath(), dst.CommandPath()))
}
return
}
if !dstOK && srcOK {
if !isNeutralMergePlaceholder(dst) {
panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q",
src.CommandPath(), dst.CommandPath()))
}
corecmd.ApplyGroupPolicy(dst, srcPolicy)
return
}
if dstOK && srcOK && dstPolicy != srcPolicy {
// A NavigationOnly/Reject/Sibling source is the framework's neutral
// service scaffold (shortcuts and plugin overlays use it before being
// folded into an owning product root). The destination owns the merged
// command's default action and recovery scope, so preserve its policy.
// Any stronger source declaration would lose behavior during this
// destination-oriented merge and therefore fails closed.
if srcPolicy != (corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
}) {
panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v",
dst.CommandPath(), dstPolicy, srcPolicy))
}
return
}
}
// isNeutralMergePlaceholder reports whether cmd contributes metadata only.
// Such a shell may adopt (or be folded into) one typed group declaration.
// Anything executable, parse-affecting, or child-bearing must declare its own
// compatible GroupPolicy so tree assembly cannot silently discard behavior.
func isNeutralMergePlaceholder(cmd *cobra.Command) bool {
if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil ||
cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil ||
cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil ||
cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil ||
cmd.TraverseChildren || cmd.DisableFlagParsing {
return false
}
hasFlags := false
cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
return !hasFlags
}
// ValidateGroupTree checks the final assembled Cobra tree rather than source
// syntax. Every command with children must carry one valid typed GroupPolicy;
// leaves must carry none. This catches dynamically assembled aliases, plugin
// parents, and constructors outside any one source directory.
func ValidateGroupTree(root *cobra.Command) error {
if root == nil {
return fmt.Errorf("cannot validate a nil command tree")
}
return validateGroupNode(root)
}
func validateGroupNode(cmd *cobra.Command) error {
policy, declared, err := corecmd.GroupPolicyFor(cmd)
if err != nil {
return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err)
}
children := cmd.Commands()
if len(children) == 0 {
if declared {
return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy)
}
return nil
}
if !declared {
return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath())
}
if !cmd.Runnable() {
return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode)
}
if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) {
return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath())
}
if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil {
return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath())
}
if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil {
return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath())
}
if policy.Positionals == corecmd.PositionalsReject {
if cmd.Args == nil {
return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath())
}
}
if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) {
return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath())
}
for _, child := range children {
if err := validateGroupNode(child); err != nil {
return err
}
}
return nil
}
func hasAvailableDescendant(cmd *cobra.Command) bool {
for _, child := range cmd.Commands() {
if !child.IsAvailableCommand() {
continue
}
return true
}
return false
}
// ShouldReplaceLeaf decides whether src should replace dst as a leaf command
// based on override priority and local flag count.
func ShouldReplaceLeaf(dst, src *cobra.Command) bool {
+391 -10
View File
@@ -14,8 +14,10 @@
package cobracmd
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
)
@@ -143,20 +145,399 @@ func TestNewGroupCommand(t *testing.T) {
t.Fatalf("Short = %q, want %q", cmd.Short, "my group description")
}
if cmd.Args == nil {
t.Fatal("Args should be set (cobra.NoArgs)")
t.Fatal("Args should be set (cobra.ArbitraryArgs)")
}
// Verify Args rejects arguments.
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
t.Fatal("expected Args to reject extra arguments")
// Args must reach the shared resolver instead of Cobra's generic arg error.
if err := cmd.Args(cmd, []string{"extra"}); err != nil {
t.Fatalf("Args intercepted command resolution: %v", err)
}
// Verify RunE is set and returns help (no error for valid invocation).
if cmd.RunE == nil {
t.Fatal("RunE should not be nil")
}
policy, ok, err := corecmd.GroupPolicyFor(cmd)
if err != nil || !ok || policy != (corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
}) {
t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err)
}
// RunE calls cmd.Help() which should not error.
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("RunE returned unexpected error: %v", err)
}
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("RunE typo error = %v", err)
}
}
func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) {
t.Run("valid final tree", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
nested := NewGroupCommand("nested", "nested")
nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.AddCommand(nested)
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("nil tree", func(t *testing.T) {
if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") {
t.Fatalf("ValidateGroupTree(nil) = %v", err)
}
})
t.Run("children require declaration", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("leaf rejects stale declaration", func(t *testing.T) {
leaf := NewGroupCommand("stale", "stale")
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) {
leaf := &cobra.Command{Use: "stale-deep"}
corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("declared group must stay runnable", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = nil
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.Args = nil
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("allowed positionals require explicit Args contract", func(t *testing.T) {
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("validation does not execute positional contracts", func(t *testing.T) {
calls := 0
root := &cobra.Command{
Use: "dws",
Args: func(*cobra.Command, []string) error {
calls++
return nil
},
RunE: func(*cobra.Command, []string) error { return nil },
}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
if calls != 0 {
t.Fatalf("ValidateGroupTree executed Args %d times", calls)
}
})
t.Run("deep recovery requires available descendants", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
}
func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) {
t.Run("malformed policy metadata", func(t *testing.T) {
root := &cobra.Command{
Use: "dws",
Annotations: map[string]string{
"dws.internal.corecmd.group_policy.v1": "malformed",
},
}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("navigation-only execution hook changed", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = func(*cobra.Command, []string) {}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("hybrid business execution hook removed", func(t *testing.T) {
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.RunE = nil
root.Run = func(*cobra.Command, []string) {}
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("nested validation error is propagated", func(t *testing.T) {
root := NewGroupCommand("dws", "root")
root.AddCommand(NewGroupCommand("stale", "stale"))
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
t.Run("deep recovery accepts an available descendant", func(t *testing.T) {
root := &cobra.Command{Use: "dws"}
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
if err := ValidateGroupTree(root); err != nil {
t.Fatalf("ValidateGroupTree() = %v", err)
}
})
}
func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) {
t.Run("copies source declaration", func(t *testing.T) {
dst := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
MergeCommandTree(dst, src)
policy, ok, err := corecmd.GroupPolicyFor(dst)
if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling {
t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err)
}
})
t.Run("typed destination accepts metadata-only source", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root", Long: "source details"}
MergeCommandTree(dst, src)
if dst.Long != "source details" {
t.Fatalf("Long = %q", dst.Long)
}
})
t.Run("accepts identical declarations", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := NewGroupCommand("root", "source")
MergeCommandTree(dst, src)
})
t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) {
businessCalled := false
dst := &cobra.Command{
Use: "root",
RunE: func(*cobra.Command, []string) error {
businessCalled = true
return nil
},
}
want := corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
}
corecmd.ApplyGroupPolicy(dst, want)
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
src := NewGroupCommand("root", "neutral scaffold")
src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }})
MergeCommandTree(dst, src)
got, ok, err := corecmd.GroupPolicyFor(dst)
if err != nil || !ok || got != want {
t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want)
}
if ChildByName(dst, "overlay") == nil {
t.Fatal("neutral scaffold child was not merged")
}
if err := dst.RunE(dst, nil); err != nil || !businessCalled {
t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err)
}
})
t.Run("rejects conflicting declarations", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) {
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
src := &cobra.Command{Use: "root"}
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) {
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
src := NewGroupCommand("root", "source")
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
src.Flags().String("source-only", "", "must not be silently discarded")
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root", Args: cobra.NoArgs}
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, src)
})
t.Run("rejects undeclared destination group", func(t *testing.T) {
dst := &cobra.Command{Use: "root"}
dst.AddCommand(&cobra.Command{Use: "child"})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(dst, &cobra.Command{Use: "root"})
})
t.Run("rejects undeclared source group", func(t *testing.T) {
src := &cobra.Command{Use: "root"}
src.AddCommand(&cobra.Command{Use: "child"})
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") {
t.Fatalf("MergeCommandTree panic = %v", got)
}
}()
MergeCommandTree(&cobra.Command{Use: "root"}, src)
})
}
func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) {
mustPanic := func(t *testing.T, want string, fn func()) {
t.Helper()
defer func() {
got := recover()
message, ok := got.(string)
if !ok || !strings.Contains(message, want) {
t.Fatalf("panic = %v, want substring %q", got, want)
}
}()
fn()
}
malformed := func() *cobra.Command {
return &cobra.Command{
Use: "root",
Annotations: map[string]string{
"dws.internal.corecmd.group_policy.v1": "malformed",
},
}
}
t.Run("malformed destination policy", func(t *testing.T) {
mustPanic(t, "destination command", func() {
MergeCommandTree(malformed(), &cobra.Command{Use: "root"})
})
})
t.Run("malformed source policy", func(t *testing.T) {
mustPanic(t, "source command", func() {
MergeCommandTree(&cobra.Command{Use: "root"}, malformed())
})
})
t.Run("local flag prevents placeholder merge", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
src.Flags().String("local", "", "local parse behavior")
mustPanic(t, "behavior-bearing leaf", func() {
MergeCommandTree(dst, src)
})
})
t.Run("persistent flag prevents placeholder merge", func(t *testing.T) {
dst := NewGroupCommand("root", "destination")
src := &cobra.Command{Use: "root"}
src.PersistentFlags().String("persistent", "", "inherited parse behavior")
mustPanic(t, "behavior-bearing leaf", func() {
MergeCommandTree(dst, src)
})
})
}
func TestNewHiddenGroupCommand(t *testing.T) {
@@ -341,11 +722,11 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("child merge recursive", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dstChild := &cobra.Command{Use: "sub", Short: ""}
dst.AddCommand(dstChild)
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
srcChild := &cobra.Command{Use: "sub", Short: "Merged short"}
src.AddCommand(srcChild)
@@ -361,12 +742,12 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("leaf replacement by higher priority", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dstLeaf := &cobra.Command{Use: "leaf", Short: "old"}
SetOverridePriority(dstLeaf, 1)
dst.AddCommand(dstLeaf)
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
srcLeaf := &cobra.Command{Use: "leaf", Short: "new"}
SetOverridePriority(srcLeaf, 5)
src.AddCommand(srcLeaf)
@@ -383,10 +764,10 @@ func TestMergeCommandTree(t *testing.T) {
t.Run("new child addition", func(t *testing.T) {
t.Parallel()
dst := &cobra.Command{Use: "root"}
dst := NewGroupCommand("root", "destination")
dst.AddCommand(&cobra.Command{Use: "existing"})
src := &cobra.Command{Use: "root"}
src := NewGroupCommand("root", "source")
src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"})
MergeCommandTree(dst, src)
+8 -6
View File
@@ -11,11 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
// Package corecmd is the shared, dispatch-agnostic base for building leaf
// commands. It concentrates flag registration, the alias/env/default effective
// value fallback chain, required validation, cross-flag constraint declaration
// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs
// assembly, and Agent Runtime Schema projection.
// Package corecmd is the shared, dispatch-agnostic base for building commands.
// It concentrates typed group policy, flag registration, the alias/env/default
// effective value fallback chain, required validation, cross-flag constraint
// declaration checks + runtime enforcement, SafetySpec-driven confirmation,
// toolArgs assembly, and Agent Runtime Schema projection.
//
// Declaration vs execution (framework rule):
//
@@ -231,7 +231,9 @@ const (
// or assemble business params that belong in Flags/ConstParams.
//
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
// validates this at construction time. corecmd stays dispatch-agnostic and
// validates this at construction time. Non-leaf commands are declared
// separately through ApplyGroupPolicy so leaf execution fields can never be
// configured and then silently ignored. corecmd stays dispatch-agnostic and
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
// body.
type Spec struct {
+218
View File
@@ -0,0 +1,218 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package corecmd
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// GroupMode declares whether a command with children is navigation-only or
// also owns business execution. The zero value means the command is a leaf.
type GroupMode string
const (
// GroupNavigationOnly is a parent whose own invocation only renders help.
GroupNavigationOnly GroupMode = "navigation_only"
// GroupHybrid is a runnable business command that also owns children.
GroupHybrid GroupMode = "hybrid"
)
// PositionalsPolicy declares whether a group may consume positional values.
type PositionalsPolicy string
const (
// PositionalsReject makes every unmatched positional token eligible for
// command-resolution recovery rather than business execution.
PositionalsReject PositionalsPolicy = "reject"
// PositionalsAllow reserves positional values for the group's business
// execution. Recovery must therefore be disabled to avoid ambiguity.
PositionalsAllow PositionalsPolicy = "allow"
)
// RecoveryPolicy declares the search scope for unknown-command recovery.
type RecoveryPolicy string
const (
// RecoverySibling suggests only direct children of the current group.
RecoverySibling RecoveryPolicy = "sibling"
// RecoveryDeep may search all descendants of the current group.
RecoveryDeep RecoveryPolicy = "deep"
// RecoveryDisabled leaves positional handling entirely to Cobra or the
// command's business execution.
RecoveryDisabled RecoveryPolicy = "disabled"
)
// GroupPolicy is the typed declaration for every non-leaf command.
//
// Its zero value deliberately means "leaf": callers must declare all three
// fields together for a group. ApplyGroupPolicy compiles the declaration to
// Cobra behavior and private framework metadata; command authors must not
// author parallel kind annotations themselves.
type GroupPolicy struct {
Mode GroupMode
Positionals PositionalsPolicy
Recovery RecoveryPolicy
}
const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1"
// IsZero reports whether p is the leaf declaration.
func (p GroupPolicy) IsZero() bool {
return p.Mode == "" && p.Positionals == "" && p.Recovery == ""
}
// ValidateGroupPolicy rejects partial declarations, unknown enum values, and
// combinations whose parsing semantics would be ambiguous.
func ValidateGroupPolicy(p GroupPolicy) error {
if p.IsZero() {
return nil
}
switch p.Mode {
case GroupNavigationOnly, GroupHybrid:
default:
return fmt.Errorf("invalid group mode %q", p.Mode)
}
switch p.Positionals {
case PositionalsReject, PositionalsAllow:
default:
return fmt.Errorf("invalid group positionals policy %q", p.Positionals)
}
switch p.Recovery {
case RecoverySibling, RecoveryDeep, RecoveryDisabled:
default:
return fmt.Errorf("invalid group recovery policy %q", p.Recovery)
}
if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject {
return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject)
}
if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled {
return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled)
}
return nil
}
// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior.
// Invalid declarations panic because they are framework construction bugs,
// matching the fail-closed behavior of Spec flag/constraint declarations.
//
// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid
// groups retain their existing RunE; when they reject positionals, a wrapper
// sends non-empty args to the same unknown-command resolver before invoking
// business execution. When recovery is enabled, rejecting positionals
// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the
// token with a generic error before command resolution can produce bounded
// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs.
func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) {
if cmd == nil {
panic("cannot apply GroupPolicy to a nil command")
}
if policy.IsZero() {
panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name()))
}
if err := ValidateGroupPolicy(policy); err != nil {
panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err))
}
if existing, ok, err := GroupPolicyFor(cmd); err != nil {
panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err))
} else if ok && existing != policy {
panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy))
} else if ok {
return
}
if policy.Mode == GroupNavigationOnly {
cmd.Run = nil
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runGroupPolicy(cmd, args, policy)
}
} else if cmd.RunE == nil {
panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name()))
} else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
businessRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return runGroupPolicy(cmd, args, policy)
}
return businessRunE(cmd, args)
}
}
if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
cmd.Args = cobra.ArbitraryArgs
} else if policy.Positionals == PositionalsReject {
cmd.Args = cobra.NoArgs
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy)
}
// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is
// false only for a leaf. Malformed private metadata is returned as an error so
// tree assembly can fail closed instead of silently treating a group as a leaf.
func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) {
if cmd == nil || cmd.Annotations == nil {
return GroupPolicy{}, false, nil
}
raw, ok := cmd.Annotations[groupPolicyAnnotation]
if !ok {
return GroupPolicy{}, false, nil
}
parts := strings.Split(raw, "|")
if len(parts) != 3 {
return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw)
}
policy := GroupPolicy{
Mode: GroupMode(parts[0]),
Positionals: PositionalsPolicy(parts[1]),
Recovery: RecoveryPolicy(parts[2]),
}
if err := ValidateGroupPolicy(policy); err != nil {
return GroupPolicy{}, false, err
}
if policy.IsZero() {
return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero")
}
return policy, true, nil
}
func encodeGroupPolicy(policy GroupPolicy) string {
return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery)
}
func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error {
if len(args) == 0 {
return cmd.Help()
}
input := strings.TrimSpace(args[0])
reason := cmdutil.ClassifyCommandResolution(cmd, input)
suggestions := cmdutil.SuggestSubcommands(cmd, input)
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep {
if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 {
suggestions = deep
}
}
return cmdutil.NewCommandResolution(
cmd,
input,
reason,
suggestions,
"",
).Err()
}
+323
View File
@@ -0,0 +1,323 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package corecmd
import (
"errors"
"runtime"
"slices"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) {
valid := []GroupPolicy{
{},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep},
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep},
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled},
}
for _, policy := range valid {
if err := ValidateGroupPolicy(policy); err != nil {
t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err)
}
}
invalid := []struct {
name string
policy GroupPolicy
needle string
}{
{name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"},
{name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"},
{name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"},
{name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"},
{name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"},
{name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) {
t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle)
}
})
}
}
func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) {
policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, policy)
got, ok, err := GroupPolicyFor(cmd)
if err != nil || !ok || got != policy {
t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy)
}
if !cmd.Runnable() || cmd.TraverseChildren {
t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren)
}
if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil {
t.Fatal("PositionalsReject must let command resolution inspect unmatched args")
}
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("navigation recovery error = %v", err)
}
var help strings.Builder
cmd.SetOut(&help)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("navigation help error = %v", err)
}
if output := help.String(); !strings.Contains(output, "Usage:") {
t.Fatalf("navigation help output = %q", output)
}
// Re-applying the same declaration is idempotent.
ApplyGroupPolicy(cmd, policy)
traversing := &cobra.Command{Use: "traversing", TraverseChildren: true}
ApplyGroupPolicy(traversing, policy)
if !traversing.TraverseChildren {
t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface")
}
leaf := &cobra.Command{Use: "leaf"}
if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() {
t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
}
annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}}
if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() {
t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
ApplyGroupPolicy(root, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
parent := &cobra.Command{
Use: "search",
RunE: func(*cobra.Command, []string) error { return nil },
}
parent.Flags().String("dimension", "", "parent-only search dimension")
ApplyGroupPolicy(parent, GroupPolicy{
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
childCalled := false
child := &cobra.Command{
Use: "enterprise",
RunE: func(*cobra.Command, []string) error {
childCalled = true
return nil
},
}
parent.AddCommand(child)
root.AddCommand(parent)
root.SetArgs([]string{"search", "--dimension", "name", "enterprise"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") {
t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err)
}
if childCalled {
t.Fatal("parent local flag leaked into child command execution")
}
}
func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) {
called := false
cmd := &cobra.Command{
Use: "hybrid",
RunE: func(*cobra.Command, []string) error {
called = true
return nil
},
}
ApplyGroupPolicy(cmd, GroupPolicy{
Mode: GroupHybrid,
Positionals: PositionalsAllow,
Recovery: RecoveryDisabled,
})
if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called {
t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) {
called := false
wrapperFrames := 0
cmd := &cobra.Command{
Use: "hybrid",
RunE: func(*cobra.Command, []string) error {
called = true
pcs := make([]uintptr, 32)
frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)])
for {
frame, more := frames.Next()
if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") {
wrapperFrames++
}
if !more {
break
}
}
return nil
},
}
policy := GroupPolicy{
Mode: GroupHybrid,
Positionals: PositionalsReject,
Recovery: RecoverySibling,
}
ApplyGroupPolicy(cmd, policy)
// Applying the identical declaration must be a no-op. In particular, it
// must not wrap the already wrapped Hybrid RunE a second time.
ApplyGroupPolicy(cmd, policy)
if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
t.Fatalf("hybrid typo error = %v", err)
}
if called {
t.Fatal("unknown positional must not reach hybrid business RunE")
}
if err := cmd.RunE(cmd, nil); err != nil || !called {
t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err)
}
if wrapperFrames != 1 {
t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
sheet := &cobra.Command{Use: "sheet"}
rangeGroup := &cobra.Command{Use: "range"}
rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
ApplyGroupPolicy(rangeGroup, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
sheet.AddCommand(
rangeGroup,
&cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}},
)
ApplyGroupPolicy(sheet, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep,
})
root.AddCommand(sheet)
err := sheet.RunE(sheet, []string{"read"})
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("deep recovery error = %T %v", err, err)
}
if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) ||
structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` {
t.Fatalf("deep recovery = %#v", structured)
}
if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) {
t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"])
}
err = sheet.RunE(sheet, []string{"+list-sheet"})
structured = nil
if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) {
t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err)
}
if !slices.Equal(structured.Actions, []string{
"Run 'dws sheet --help' for the full list",
"Run 'dws shortcut list --service sheet --format json'",
}) {
t.Fatalf("direct shortcut actions = %#v", structured.Actions)
}
}
func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) {
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, GroupPolicy{
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled,
})
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra")
}
}
func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) {
mustPanic := func(name, needle string, fn func()) {
t.Helper()
t.Run(name, func(t *testing.T) {
defer func() {
got := recover()
if got == nil || !strings.Contains(got.(string), needle) {
t.Fatalf("panic = %v, want %q", got, needle)
}
}()
fn()
})
}
mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) })
mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) })
mustPanic("invalid policy", "invalid GroupPolicy", func() {
ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{
Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled,
})
})
mustPanic("hybrid must run", "must declare RunE", func() {
ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
})
})
mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() {
cmd := &cobra.Command{Use: "parent"}
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled})
})
mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() {
cmd := &cobra.Command{
Use: "broken",
Annotations: map[string]string{
groupPolicyAnnotation: "hybrid|reject|unexpected",
},
}
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
})
}
func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) {
for name, test := range map[string]struct {
encoded string
needle string
}{
"malformed": {encoded: "hybrid|reject", needle: "malformed"},
"invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"},
"zero policy": {encoded: "||", needle: "must not be zero"},
} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{
Use: "broken",
Annotations: map[string]string{
groupPolicyAnnotation: test.encoded,
},
}
if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) {
t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle)
}
})
}
}
+8 -8
View File
@@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation
// ──────────────────────────────────────────────────────────
func newAgoalCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "agoal",
Short: "Agoal 管理",
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
@@ -39,11 +39,11 @@ func newAgoalCommand() *cobra.Command {
dws agoal obj-template list 获取目标模板列表
dws agoal obj-template create-or-update 新增或更新目标模板`,
RunE: groupRunE,
}
})
// ── strategy: 战略解码管理 ──────────────────────────────────
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE})
strategyListCmd := &cobra.Command{
Use: "list",
@@ -139,7 +139,7 @@ scopeType 支持:
// ── contract: 经营合约管理 ──────────────────────────────────
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE})
contractListCmd := &cobra.Command{
Use: "list",
@@ -258,7 +258,7 @@ scopeType 支持:
// ── scorecard: 计分卡管理 ───────────────────────────────────
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE})
scorecardDetailCmd := &cobra.Command{
Use: "detail",
@@ -394,7 +394,7 @@ scopeType 支持:
// ── user: 用户目标管理 ──────────────────────────────────────
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
userRulesCmd := &cobra.Command{
Use: "rules",
@@ -445,7 +445,7 @@ scopeType 支持:
// ── report: 周月报管理 ──────────────────────────────────────
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE})
reportListStatisticsCmd := &cobra.Command{
Use: "list-statistics",
@@ -520,7 +520,7 @@ scopeType 支持:
// ── template: 目标模板管理 ──────────────────────────────────
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE})
objTemplateListCmd := &cobra.Command{
Use: "list",
+1
View File
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
return groupRunE(cmd, args)
},
}
newHybridGroupCommand(root)
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --query xxx ← root 自己能解析
+26 -24
View File
@@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "aitable",
Short: "AI 表格操作",
Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。
@@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command {
dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`,
RunE: groupRunE,
SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos
}
})
// ── base: Base 管理 ─────────────────────────────────────────
baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}
baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE})
baseGetPrimaryDocIdCmd := &cobra.Command{
Use: "get-primary-doc-id",
@@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错
// ── table: 数据表管理 ───────────────────────────────────────
tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}
tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE})
tableGetCmd := &cobra.Command{
Use: "get",
@@ -2223,7 +2223,7 @@ config 结构参考:
// ── field: 字段管理 ─────────────────────────────────────────
fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}
fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE})
fieldGetCmd := &cobra.Command{
Use: "get",
@@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。
// ── record: 记录管理 ────────────────────────────────────────
recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}
recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE})
recordQueryCmd := &cobra.Command{
Use: "query",
@@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── template: 模板搜索 ──────────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE})
templateSearchCmd := &cobra.Command{
Use: "search",
@@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── attachment: 附件管理 ──────────────────────────────────────
attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE})
attachmentUploadCmd := &cobra.Command{
Use: "upload",
@@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
// ── view: 视图管理 ───────────────────────────────────────────
viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}
viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE})
viewGetCmd := &cobra.Command{
Use: "get",
@@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
return callAitableTool("get_views", toolArgs)
},
}
newHybridGroupCommand(viewGetCmd)
// ─── view get <attr> 子命令:按属性投影 view 响应 ──────────────
// card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。
@@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。
return callAitableTool("update_view", toolArgs)
},
}
newHybridGroupCommand(viewUpdateCmd)
// ─── view update <attr> 子命令:按属性局部更新 ────────────────────
@@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── form: 表单管理 ──────────────────────────────────────────
formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}
formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}
formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}
formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE})
formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE})
formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE})
formListCmd := &cobra.Command{
Use: "list",
@@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ──
formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}
formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE})
formQuestionsCreateCmd := &cobra.Command{
Use: "create",
@@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
// ── workflow: 自动化工作流管理 ────────────────────────────────
workflowCmd := &cobra.Command{
workflowCmd := newGroupCommand(&cobra.Command{
Use: "workflow",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
RunE: groupRunE,
}
})
workflowCreateCmd := &cobra.Command{
Use: "create",
@@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
// ── dashboard: 仪表盘管理 ────────────────────────────────────
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE})
dashboardConfigExampleCmd := &cobra.Command{
Use: "config-example",
@@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── dashboard share: 仪表盘分享管理 ────────────────────────────
dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}
dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE})
dashboardShareGetCmd := &cobra.Command{
Use: "get",
@@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── chart: 图表管理 ──────────────────────────────────────────
chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}
chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE})
chartWidgetsExampleCmd := &cobra.Command{
Use: "widgets-example",
@@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── chart share: 图表分享管理 ────────────────────────────────
chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}
chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE})
chartShareGetCmd := &cobra.Command{
Use: "get",
@@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
// ── export / import: 数据导入导出 ────────────────────────────
exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}
exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE})
exportDataCmd := &cobra.Command{
Use: "data",
@@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with
},
})
importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}
importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE})
// ── advperm: 高级权限 / 自定义角色 ────────────────────────────
advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}
advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE})
advpermEnableCmd := &cobra.Command{
Use: "enable",
@@ -7392,7 +7394,7 @@ role-get 自行 merge)。
// ── section: 文件夹与节点管理(导航树组织) ──────────────────────────────
sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}
sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE})
sectionCreateCmd := &cobra.Command{
Use: "create",
@@ -8424,7 +8426,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
// ── datasource: 数据源同步管理 ──────────────────────────────
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
datasourceGetConfigCmd := &cobra.Command{
Use: "get-config",
+23 -23
View File
@@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "attendance",
Short: "考勤打卡 / 排班 / 统计",
Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。
@@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command {
globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知)
vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`,
RunE: groupRunE,
}
})
// ── record ───────────────────────────────────────────────
attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}
attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE})
attendanceRecordGetCmd := &cobra.Command{
Use: "get",
@@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command {
// ── check ────────────────────────────────────────────────
attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}
attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE})
// MCP tool: query_check_result
attendanceCheckResultCmd := &cobra.Command{
@@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command {
// ── approve ────────────────────────────────────────────────
attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}
attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE})
// 审批类型关键词到 bizType 数字映射
// 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类),
@@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command {
// ── shift ────────────────────────────────────────────────
attendanceShiftCmd := &cobra.Command{
attendanceShiftCmd := newGroupCommand(&cobra.Command{
Use: "shift",
Short: "班次查询",
Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。
返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`,
RunE: groupRunE,
}
})
// MCP tool: batch_get_employee_shifts
attendanceShiftListCmd := &cobra.Command{
@@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command {
// ── class ────────────────────────────────────────────────
attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}
attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE})
// MCP tool: get_class_list
attendanceClassSearchCmd := &cobra.Command{
@@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── adjustment-rule ────────────────────────────────────
attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}
attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE})
// MCP tool: get_adjustment_rule_detail
attendanceAdjustmentGetCmd := &cobra.Command{
@@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── overtime-rule ──────────────────────────────────────
attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}
attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE})
// MCP tool: get_overtime_rule_detail
attendanceOvertimeGetCmd := &cobra.Command{
@@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
// ── group ──────────────────────────────────────────────
attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}
attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE})
// MCP tool: get_simple_groups
attendanceGroupSearchCmd := &cobra.Command{
@@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
})
// ── selfsetting ─────────────────────────────────────────────
attendanceSelfSettingCmd := &cobra.Command{
attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{
Use: "selfsetting",
Short: "个人规则设置",
Long: `个人规则设置相关命令。
@@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
RunE: groupRunE,
}
})
// MCP tool: query_self_setting
attendanceSelfSettingGetCmd := &cobra.Command{
@@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
})
// ── globalsetting ────────────────────────────────────────
attendanceGlobalSettingCmd := &cobra.Command{
attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{
Use: "globalsetting",
Short: "全局规则设置(仅管理员)",
Long: `全局规则设置相关命令,仅管理员可以调用。
@@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
RunE: groupRunE,
}
})
// MCP tool: query_global_setting
attendanceGlobalSettingGetCmd := &cobra.Command{
@@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── report ──────────────────────────────────────────────
attendanceReportCmd := &cobra.Command{
attendanceReportCmd := newGroupCommand(&cobra.Command{
Use: "report",
Short: "查询考勤报表和结果",
Long: `考勤 MCP 报表接口,仅对管理员开放
@@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
query-data 根据字段查询考勤数据
query-leave 查询用户假期数据`,
RunE: groupRunE,
}
})
// MCP tool: get_report_columns
reportColumnsCmd := &cobra.Command{
@@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── 假期 vacation ───────────────────────────────────────────────
attendanceVacationCmd := &cobra.Command{
attendanceVacationCmd := newGroupCommand(&cobra.Command{
Use: "vacation",
Short: "假期管理",
Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。
@@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
save-balance 更新员工假期余额
records 查询指定员工假期余额变更记录`,
RunE: groupRunE,
}
})
// ── 假期规则 types ─────────────────────────────────────────
@@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── schedule ──────────────────────────────────────────────
attendanceScheduleCmd := &cobra.Command{
attendanceScheduleCmd := newGroupCommand(&cobra.Command{
Use: "schedule",
Short: "排班管理",
Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。
@@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
import 导入排班记录到排班制考勤组
get 获取指定用户的排班记录`,
RunE: groupRunE,
}
})
// schedule import (generateTurnSchedule)
scheduleImportCmd := &cobra.Command{
@@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
// ── checkin ──────────────────────────────────────────────
checkinCmd := &cobra.Command{
checkinCmd := newGroupCommand(&cobra.Command{
Use: "checkin",
Short: "签到管理",
Long: `签到记录的查询。
子命令:
records 查询指定员工的签到记录`,
RunE: groupRunE,
}
})
// MCP tool: queryUserRecordByStaffIds
checkinRecordsCmd := &cobra.Command{
+13 -200
View File
@@ -2,14 +2,11 @@ package helpers
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -18,10 +15,9 @@ import (
// dws calendar — 日历产品命令组
// ──────────────────────────────────────────────────────────
// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that prints
// a warning-level "Did you mean" hint to stderr (instead of returning an Error)
// and exits 0. Scoped to calendar.go on purpose so the shared cmdutil.HintSubCmd
// used by other products keeps returning errors as before.
// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that returns
// the shared typed validation error while preserving Calendar's reviewed
// replacement guidance.
//
// The `suggestion` argument should be the bare corrected command (no leading
// "use:" / "hint:" prefix); the helper wraps it with the standard "Did you
@@ -29,186 +25,9 @@ import (
func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command {
c := hintSubCmd(use, suggestion)
c.DisableFlagParsing = true
c.RunE = func(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "warning: command %q does not exist\n hint: %s\t %s\n more: %s \n",
cmd.Parent().CommandPath()+" "+use,
suggestion,
"[MUST] use --help to see command detail",
"'dws calendar --help' to see all available commands")
return nil
}
return c
}
// installUnknownVerbFallback makes `group` emit a consistent warning-style
// "Did you mean" hint whenever the caller types an unknown subcommand under
// that group, regardless of whether extra flags follow. This is a blanket
// safety net that covers every verb we never thought to pre-register via
// calendarInfoHintSubCmd (e.g. `dws calendar room query --min-duration 30`).
//
// Two Cobra knobs make this work together:
// - FParseErrWhitelist.UnknownFlags=true stops pflag from aborting with
// "unknown flag: --xxx" before RunE ever runs.
// - Args=cobra.ArbitraryArgs lets Cobra pass the bad verb through as the
// first positional arg instead of rejecting it.
//
// If the user types a *known* subcommand, Cobra still dispatches to that
// child's RunE as usual; this fallback only fires when resolution stops at
// `group` with leftover args.
func installUnknownVerbFallback(group *cobra.Command) {
group.FParseErrWhitelist.UnknownFlags = true
group.Args = cobra.ArbitraryArgs
// Override HelpFunc so that `<group> <unknown-verb> --help` also shows
// the "unknown subcommand" error instead of silently printing help.
// Cobra intercepts --help before RunE, so without this the fallback
// would never fire when --help is present.
origHelp := group.HelpFunc()
group.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd == group {
// HelpFunc receives os.Args[1:] (full arg slice without binary).
// Strip tokens matching the resolved command path to get actual
// leftover args that should be checked for unknown verbs.
depth := len(strings.Fields(cmd.CommandPath())) - 1
leftover := stripCommandPrefix(args, depth)
if bad := findUnknownVerb(cmd, leftover); bad != "" {
printUnknownSubcmdError(cmd, bad)
return
}
origHelp(cmd, args)
return
}
// For non-group commands, render base help then apply the safety
// annotation. Recursion safety hinges on NOT calling
// cmd.Root().HelpFunc(): in test trees calendar IS the root, so that
// would re-enter this wrapper. origHelp was captured before any
// wrapping and is the plain cobra renderer.
origHelp(cmd, args)
cli.RenderSafetyAnnotation(cmd)
})
prev := group.RunE
group.RunE = func(cmd *cobra.Command, args []string) error {
// Unknown flags whitelisted by pflag may leak into args. Pick the first
// non-flag token as the offending verb.
if bad := findUnknownVerb(cmd, args); bad != "" {
printUnknownSubcmdError(cmd, bad)
return nil
}
// No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently
// swallows bad flags, scan the original os.Args for flags unregistered
// on this command and report them explicitly.
if flag := findUnknownFlag(cmd); flag != "" {
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", flag)
fmt.Fprintf(os.Stderr, " hint: Run '%s --help' to see available options\n", cmd.CommandPath())
return nil
}
if prev != nil {
return prev(cmd, args)
}
return cmd.Help()
}
}
// findUnknownVerb returns the first positional arg that is not a registered
// subcommand (or alias) of cmd. Returns "" if all args are flags or known.
func findUnknownVerb(cmd *cobra.Command, args []string) string {
for _, a := range args {
if strings.HasPrefix(a, "-") {
continue
}
isKnown := false
for _, c := range cmd.Commands() {
if c.Name() == a {
isKnown = true
break
}
for _, alias := range c.Aliases {
if alias == a {
isKnown = true
break
}
}
if isKnown {
break
}
}
if !isKnown {
return a
}
}
return ""
}
// printUnknownSubcmdError prints the standard "unknown subcommand" error to
// stderr with available commands and a did-you-mean hint.
func printUnknownSubcmdError(cmd *cobra.Command, bad string) {
var available []string
for _, c := range cmd.Commands() {
if !c.Hidden && c.Name() != "help" {
available = append(available, c.Name())
}
}
fmt.Fprintf(os.Stderr, "Error: unknown subcommand %q for %q\n", bad, cmd.CommandPath())
fmt.Fprintf(os.Stderr, " available: %s\n", strings.Join(available, ", "))
if s := cmd.SuggestionsFor(bad); len(s) > 0 {
fmt.Fprintf(os.Stderr, " hint: did you mean %q\n", cmd.CommandPath()+" "+s[0])
} else {
fmt.Fprintf(os.Stderr, " hint: %s --help\n", cmd.CommandPath())
}
}
// stripCommandPrefix strips the first `depth` non-flag tokens from args.
// This is needed because Cobra's HelpFunc receives os.Args[1:] (the full arg
// slice without the binary name), including the resolved command path tokens.
// depth should be len(strings.Fields(cmd.CommandPath())) - 1.
func stripCommandPrefix(args []string, depth int) []string {
skipped := 0
for i, a := range args {
if skipped >= depth {
return args[i:]
}
if !strings.HasPrefix(a, "-") {
skipped++
}
}
return nil
}
// findUnknownFlag scans os.Args for flags that are not registered on cmd.
// Returns the first offending flag token (e.g. "--today") or "".
func findUnknownFlag(cmd *cobra.Command) string {
depth := len(strings.Fields(cmd.CommandPath())) - 1
leftover := stripCommandPrefix(os.Args[1:], depth)
for i := 0; i < len(leftover); i++ {
a := leftover[i]
if a == "--" {
break
}
if strings.HasPrefix(a, "--") {
name := a[2:]
if eqIdx := strings.Index(name, "="); eqIdx >= 0 {
name = name[:eqIdx]
}
if name == "help" {
continue
}
if cmd.Flags().Lookup(name) == nil {
return a
}
} else if strings.HasPrefix(a, "-") && a != "-" {
ch := a[1:2]
if ch == "h" {
continue
}
if cmd.Flags().ShorthandLookup(ch) == nil {
return a
}
}
}
return ""
}
func newCalendarCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/calendar.json
// products.calendar). Catalog assembly stamps provenance contract_final.
@@ -224,7 +43,7 @@ func newCalendarCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "calendar",
Short: "日历日程 / 会议室 / 闲忙",
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
@@ -238,11 +57,11 @@ func newCalendarCommand() *cobra.Command {
dws calendar book [list|get|search|update] 日历本管理
dws calendar acl [list|add|delete] 日历访问权限管理`,
RunE: groupRunE,
}
})
// ── event: 日程 ─────────────────────────────────────────────
eventCmd := &cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE}
eventCmd := newGroupCommand(&cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE})
eventListCmd := &cobra.Command{
Use: "list",
@@ -816,13 +635,13 @@ func newCalendarCommand() *cobra.Command {
// ── attendee: 参会人 (曾用名: participant) ─────────────────
participantCmd := &cobra.Command{
participantCmd := newGroupCommand(&cobra.Command{
Use: "attendee",
Aliases: []string{"participant"},
Short: "日程参会人管理",
Long: "管理日程的参会人。alias:`participant`,仍作为别名保留,历史调用无需改动。",
RunE: groupRunE,
}
})
participantListCmd := &cobra.Command{
Use: "list",
@@ -1008,7 +827,7 @@ func newCalendarCommand() *cobra.Command {
// ── room: 会议室 ────────────────────────────────────────────
roomCmd := &cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE}
roomCmd := newGroupCommand(&cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE})
roomSearchCmd := &cobra.Command{
Use: "search",
@@ -1352,7 +1171,7 @@ func newCalendarCommand() *cobra.Command {
// ── busy: 闲忙 ──────────────────────────────────────────────
busyCmd := &cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE}
busyCmd := newGroupCommand(&cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE})
busySearchCmd := &cobra.Command{
Use: "search",
@@ -1444,7 +1263,7 @@ func newCalendarCommand() *cobra.Command {
// ── attachment: 附件 ────────────────────────────────────────
attachmentCmd := &cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE}
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE})
attachmentAddCmd := &cobra.Command{
Use: "add",
@@ -1529,7 +1348,7 @@ func newCalendarCommand() *cobra.Command {
// ── acl: 日历访问权限 ─────────────────────────────────────────
aclCmd := &cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE}
aclCmd := newGroupCommand(&cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE})
aclListCmd := &cobra.Command{
Use: "list",
@@ -1619,7 +1438,7 @@ func newCalendarCommand() *cobra.Command {
// ── book: 日历本 ────────────────────────────────────────────
bookCmd := &cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE}
bookCmd := newGroupCommand(&cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE})
bookListCmd := &cobra.Command{
Use: "list",
@@ -2657,12 +2476,6 @@ func newCalendarCommand() *cobra.Command {
root.AddCommand(eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd)
// Install the unknown-verb fallback on every group command. This covers
// arbitrary typos like `dws calendar room query --min-duration 30` that
// the per-verb calendarInfoHintSubCmd registrations below can't anticipate.
for _, g := range []*cobra.Command{root, eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd} {
installUnknownVerbFallback(g)
}
// Hint subcommands must swallow any extra flags/args the caller passes,
// otherwise `dws calendar list` prints the nice "ambiguous command" hint
// but `dws calendar list --start ...` fails earlier with cobra's
@@ -1,9 +1,10 @@
package helpers
import (
"os"
"errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -46,16 +47,13 @@ func TestCrossPlatformCoverageCalendarOptionalFlagsRemainingCoverage(t *testing.
func TestCrossPlatformCoverageCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) {
root := &cobra.Command{Use: "calendar"}
group := &cobra.Command{Use: "room"}
group := newGroupCommand(&cobra.Command{Use: "room"})
group.SuggestionsMinimumDistance = 3
group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}})
root.AddCommand(group)
installUnknownVerbFallback(group)
oldArgs := os.Args
os.Args = []string{"dws", "calendar", "room", "--unknown"}
t.Cleanup(func() { os.Args = oldArgs })
if err := group.RunE(group, nil); err != nil {
t.Fatalf("unknown flag fallback: %v", err)
err := group.RunE(group, []string{"serach"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" {
t.Fatalf("typed suggestion fallback: %#v", err)
}
printUnknownSubcmdError(group, "serach")
}
+11 -30
View File
@@ -8,6 +8,7 @@ import (
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -175,42 +176,22 @@ func TestCrossPlatformCoverageBuildRecurrenceCoverage(t *testing.T) {
func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) {
root := &cobra.Command{Use: "calendar"}
group := &cobra.Command{Use: "room", RunE: func(*cobra.Command, []string) error { return errors.New("previous") }}
group := newGroupCommand(&cobra.Command{Use: "room"})
known := &cobra.Command{Use: "search", Aliases: []string{"find"}}
hidden := &cobra.Command{Use: "secret", Hidden: true}
group.AddCommand(known, hidden)
root.AddCommand(group)
installUnknownVerbFallback(group)
_ = group.RunE(group, []string{"unknown"})
_ = group.RunE(group, []string{"--ignored", "search"})
_ = group.RunE(group, nil)
group.HelpFunc()(group, []string{"calendar", "room", "unknown"})
group.HelpFunc()(known, nil)
printUnknownSubcmdError(group, "searhc")
printUnknownSubcmdError(group, "unrelated")
var structured *apperrors.Error
if err := group.RunE(group, []string{"searhc"}); !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" {
t.Fatalf("typed group recovery = %#v", err)
}
if err := group.RunE(group, nil); err != nil {
t.Fatalf("group help = %v", err)
}
hint := calendarInfoHintSubCmd("query", "use search")
group.AddCommand(hint)
_ = hint.RunE(hint, nil)
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
group.Flags().StringP("known", "k", "", "")
for _, args := range [][]string{
{"dws", "calendar", "room", "--"},
{"dws", "calendar", "room", "--help"},
{"dws", "calendar", "room", "--known=value"},
{"dws", "calendar", "room", "--unknown=value"},
{"dws", "calendar", "room", "-h"},
{"dws", "calendar", "room", "-k", "value"},
{"dws", "calendar", "room", "-x"},
} {
os.Args = args
_ = findUnknownFlag(group)
if err := hint.RunE(hint, nil); err == nil {
t.Fatal("calendar compatibility hint succeeded")
}
nilPrev := &cobra.Command{Use: "empty"}
root.AddCommand(nilPrev)
installUnknownVerbFallback(nilPrev)
os.Args = []string{"dws", "calendar", "empty"}
_ = nilPrev.RunE(nilPrev, nil)
}
+17 -16
View File
@@ -2403,13 +2403,13 @@ func newChatCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "chat",
Aliases: []string{"im"},
Short: "群聊 / 消息 / 机器人",
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
RunE: groupRunE,
}
})
chatChmodCmd := &cobra.Command{
Use: "chmod <scope>",
@@ -2499,12 +2499,12 @@ func newChatCommand() *cobra.Command {
},
})
chatDataAuthCmd := &cobra.Command{
chatDataAuthCmd := newGroupCommand(&cobra.Command{
Use: "data-auth",
Short: "授予 chat 数据读取权限",
Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`,
RunE: groupRunE,
}
})
chatDataAuthCrossOrgCmd := &cobra.Command{
Use: "cross-org",
Short: "授予跨组织 chat 数据访问权限",
@@ -2576,7 +2576,7 @@ func newChatCommand() *cobra.Command {
// ── group 子命令 ──────────────────────────────────────────
chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE}
chatGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE})
chatGroupCreateCmd := &cobra.Command{
Use: "create",
@@ -2766,6 +2766,7 @@ func newChatCommand() *cobra.Command {
return callMCPTool("get_group_members", toolArgs)
},
}
newHybridGroupCommand(chatGroupMembersCmd)
chatGroupMembersAddBotCmd := &cobra.Command{
Use: "add-bot",
@@ -2974,12 +2975,12 @@ func newChatCommand() *cobra.Command {
// ── message 子命令 ────────────────────────────────────────
chatMessageCmd := &cobra.Command{
chatMessageCmd := newGroupCommand(&cobra.Command{
Use: "message",
Short: "会话消息管理",
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
RunE: groupRunE,
}
})
chatMessageListCmd := &cobra.Command{
Use: "list",
@@ -4674,7 +4675,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
// ── bot 子命令 ────────────────────────────────────────────
chatBotCmd := &cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE}
chatBotCmd := newGroupCommand(&cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE})
chatBotSearchCmd := &cobra.Command{
Use: "search",
@@ -5210,12 +5211,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
// ── file 子命令(会话文件上传,不暴露 spaceId)───────────────
chatFileCmd := &cobra.Command{
chatFileCmd := newGroupCommand(&cobra.Command{
Use: "file",
Short: "会话文件上传(已下线)",
Hidden: true,
RunE: groupRunE,
}
})
chatFileUploadCmd := &cobra.Command{
Use: "upload",
@@ -5250,7 +5251,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
// ── category 子命令(会话分组,走 IM MCP)───────────────────
chatCategoryCmd := &cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE}
chatCategoryCmd := newGroupCommand(&cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE})
chatCategoryListCmd := &cobra.Command{
Use: "list",
@@ -7580,7 +7581,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
// ── group-role 子命令(群身份管理)────────────────────────
chatGroupRoleCmd := &cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE}
chatGroupRoleCmd := newGroupCommand(&cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE})
chatGroupRoleListCmd := &cobra.Command{
Use: "list",
@@ -9864,7 +9865,7 @@ status 可选值:
})
// ── group notice: 群公告管理 ────────────────────────────────
chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}
chatGroupNoticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE})
chatGroupNoticeCreateCmd := &cobra.Command{
Use: "create",
@@ -10385,7 +10386,7 @@ status 可选值:
"fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true,
"he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true,
}
chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}
chatTextCmd := newGroupCommand(&cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE})
chatTextTranslateCmd := &cobra.Command{
Use: "translate",
Short: "翻译文本内容",
@@ -10450,11 +10451,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd)
// ── chat group user-settings ──
chatGroupUserSettingsCmd := &cobra.Command{
chatGroupUserSettingsCmd := newGroupCommand(&cobra.Command{
Use: "user-settings",
Short: "批量查询或更新当前用户的群会话设置",
RunE: groupRunE,
}
})
chatGroupUserSettingsQueryCmd := &cobra.Command{
Use: "query",
Short: "批量查询当前用户的群会话设置",
+9 -1
View File
@@ -13,7 +13,10 @@
package chat
import "github.com/spf13/cobra"
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/spf13/cobra"
)
func newChatToolbarCommand() *cobra.Command {
toolbarCmd := &cobra.Command{
@@ -22,6 +25,11 @@ func newChatToolbarCommand() *cobra.Command {
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
RunE: groupRunE,
}
corecmd.ApplyGroupPolicy(toolbarCmd, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
toolbarCmd.DisableAutoGenTag = true
toolbarCmd.AddCommand(
+7 -2
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -121,8 +122,12 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("chat %s with legacy flags error = %T %v, want structured validation", tc.path, err, err)
}
if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
t.Fatalf("chat %s with legacy flags error = %#v, want migration hint %q", tc.path, structured, tc.hint)
}
}
}
+1
View File
@@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command {
},
}
media.AddCommand(newChatMediaUploadCommand())
newHybridGroupCommand(media)
return media
}
+2 -2
View File
@@ -13,12 +13,12 @@ import (
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
cmd := newGroupCommand(&cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
})
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
File diff suppressed because it is too large Load Diff
-788
View File
@@ -1,788 +0,0 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
cmd := newCollegeContactCommand()
if cmd.Name() != "college-contact" {
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
}
if !cmd.Hidden {
t.Error("extension root command should be Hidden")
}
// 分组 → 叶子命令映射
groups := map[string][]string{
"dept": {
"get-standard-structure", "get-detail", "get-chain", "search",
"create", "update", "delete", "batch-update-type", "overview",
},
"employee": {
"get-detail", "add", "remove", "change-type", "change-dept",
"send-active-sms", "list-employees", "list-unaccepted",
"list-unactive", "upgrade-status", "start-upgrade",
},
"alumni": {
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
},
"graduate": {
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
},
"group": {
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
},
}
for groupName, leaves := range groups {
var groupCmd *cobra.Command
for _, c := range cmd.Commands() {
if c.Name() == groupName {
groupCmd = c
break
}
}
if groupCmd == nil {
t.Fatalf("subcommand group %q not found", groupName)
}
for _, leaf := range leaves {
found := false
for _, c := range groupCmd.Commands() {
if c.Name() == leaf {
found = true
break
}
}
if !found {
t.Errorf("leaf command %q not found under %q", leaf, groupName)
}
}
}
// stats 分组已移除
for _, c := range cmd.Commands() {
if c.Name() == "stats" {
t.Error("subcommand group 'stats' should be removed")
}
}
}
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(newCollegeContactCommand())
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
if err != nil {
t.Fatalf("command path not found: %v", err)
}
if c.Name() != "get-standard-structure" {
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
}
}
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newCollegeContactTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
return root
}
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
t.Helper()
root := newCollegeContactTestRoot()
root.SetArgs(args)
defer func() {
if r := recover(); r != nil {
panicked = true
}
}()
err = root.Execute()
return err, false
}
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if panicked {
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
}
if err == nil {
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
}
if !strings.Contains(err.Error(), "--yes") {
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
}
}
}
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
cases := [][]string{
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
}
for _, args := range cases {
err, panicked := runDestructiveLeaf(t, args...)
if !panicked {
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
}
}
}
}
// withCollegeContactCaller installs a dry-run capture caller so happy-path
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
// without requiring a live MCP transport. In dry-run mode destructive
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactHappyPaths runs every leaf command with required flags only
// and with all optional flags populated, expecting a nil error (dry-run preview).
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure"},
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-detail", "--dept-id", "123"},
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "get-chain", "--dept-id", "123"},
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
{"dept", "delete", "--dept-id", "123"},
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
{"dept", "overview"},
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail", "--staff-id", "S1"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "remove", "--staff-ids", "S1"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "send-active-sms", "--dept-id", "100"},
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-employees", "--dept-id", "123"},
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unaccepted", "--dept-id", "123"},
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "list-unactive", "--dept-id", "123"},
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
{"employee", "upgrade-status"},
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
{"employee", "start-upgrade"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
{"alumni", "get-info", "--alumni-dept-id", "123"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
{"alumni", "query", "--staff-id", "S1"},
{"alumni", "search", "--keyword", "x"},
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
{"alumni", "get-group", "--alumni-dept-id", "1"},
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
{"alumni", "create-group", "--alumni-dept-id", "1"},
{"alumni", "disband-group", "--alumni-dept-id", "1"},
{"alumni", "get-alumni-org-from-graduate"},
{"alumni", "create-alumni-org", "--org-name", "O"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-years"},
{"graduate", "query-graduate-depts", "--dept-id", "1"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
{"graduate", "get-task-result", "--request-no", "r1"},
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
{"graduate", "get-alumni-org"},
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
{"graduate", "search-graduate", "--keyword", "x"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "all-graduate", "--graduate-year", "2026"},
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule"},
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
{"group", "get-group-rule-schedule"},
{"group", "query-preview-data"},
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
{"group", "delete-group-rule", "--rule-id", "1"},
{"group", "enable-group-rule", "--rule-id", "1"},
{"group", "disable-group-rule", "--rule-id", "1"},
{"group", "set-group-rule-schedule"},
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
{"group", "execute-group-rule"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err != nil {
t.Errorf("%v: expected nil error, got: %v", args, err)
}
}
}
// TestCollegeContactValidationErrors exercises every validation-error branch:
// missing required flags, non-integer int flags, invalid bool flags, and
// empty-after-split CSV lists. Each case must return a non-nil error.
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
withCollegeContactCaller(t)
cases := [][]string{
// ── dept ─────────────────────────────────────────────
{"dept", "get-standard-structure", "--dept-id", "abc"},
{"dept", "get-standard-structure", "--offset", "abc"},
{"dept", "get-standard-structure", "--size", "abc"},
{"dept", "get-detail"},
{"dept", "get-detail", "--dept-id", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
{"dept", "get-chain"},
{"dept", "get-chain", "--dept-id", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
{"dept", "search"},
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
{"dept", "search", "--dept-id", "1"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
{"dept", "create"},
{"dept", "create", "--super-id", "abc"},
{"dept", "create", "--super-id", "100"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
{"dept", "update"},
{"dept", "update", "--dept-id", "abc"},
{"dept", "update", "--dept-id", "1"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
{"dept", "delete"},
{"dept", "delete", "--dept-id", "abc"},
{"dept", "batch-update-type"},
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
{"dept", "batch-update-type", "--dept-ids", "1,2"},
{"dept", "overview", "--dept-id", "abc"},
{"dept", "overview", "--offset", "abc"},
{"dept", "overview", "--size", "abc"},
// ── employee ─────────────────────────────────────────
{"employee", "get-detail"},
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
{"employee", "add"},
{"employee", "add", "--emp-type", "x"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
{"employee", "remove"},
{"employee", "remove", "--staff-ids", ",,"},
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
{"employee", "change-type"},
{"employee", "change-type", "--staff-id", "S1"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
{"employee", "change-dept"},
{"employee", "change-dept", "--staff-id", "S1"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
{"employee", "send-active-sms"},
{"employee", "send-active-sms", "--dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-employees"},
{"employee", "list-employees", "--dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unaccepted"},
{"employee", "list-unaccepted", "--dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "list-unactive"},
{"employee", "list-unactive", "--dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
{"employee", "upgrade-status", "--dept-id", "abc"},
{"employee", "upgrade-status", "--offset", "abc"},
{"employee", "upgrade-status", "--size", "abc"},
// ── alumni ───────────────────────────────────────────
{"alumni", "get-dept-tree"},
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
{"alumni", "get-info"},
{"alumni", "get-info", "--alumni-dept-id", "abc"},
{"alumni", "list"},
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
{"alumni", "list", "--alumni-dept-id", "1"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
{"alumni", "query"},
{"alumni", "search"},
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
{"alumni", "list-unaccepted"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
{"alumni", "get-group"},
{"alumni", "get-group", "--alumni-dept-id", "abc"},
{"alumni", "create-dept"},
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "create-dept", "--alumni-dept-id", "1"},
{"alumni", "update-dept"},
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
{"alumni", "update-dept", "--alumni-dept-id", "1"},
{"alumni", "delete-dept"},
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
{"alumni", "update-managers"},
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
{"alumni", "update-managers", "--alumni-dept-id", "1"},
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
{"alumni", "add-alumnus"},
{"alumni", "add-alumnus", "--name", "X"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
{"alumni", "update-alumnus"},
{"alumni", "update-alumnus", "--staff-id", "S1"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
{"alumni", "remove-alumnus"},
{"alumni", "remove-alumnus", "--staff-id", "S1"},
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
{"alumni", "cancel-invite"},
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
{"alumni", "create-group"},
{"alumni", "create-group", "--alumni-dept-id", "abc"},
{"alumni", "disband-group"},
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
{"alumni", "create-alumni-org"},
{"alumni", "add-alumni-org-main-admins"},
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
// ── graduate ─────────────────────────────────────────
{"graduate", "query-graduate-depts"},
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
{"graduate", "query-graduate-sub-depts"},
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users"},
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
{"graduate", "get-task-result"},
{"graduate", "query-restore-sub-depts"},
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
{"graduate", "search-graduate"},
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
{"graduate", "commit-graduate"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
{"graduate", "all-graduate"},
{"graduate", "all-graduate", "--graduate-year", "abc"},
{"graduate", "batch-graduate"},
{"graduate", "batch-graduate", "--dept-id", "abc"},
{"graduate", "batch-graduate", "--dept-id", "1"},
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "delete-and-graduate"},
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
{"graduate", "delete-and-graduate", "--dept-id", "1"},
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-delete-pending"},
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
{"graduate", "batch-delete-pending", "--dept-id", "1"},
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending"},
{"graduate", "batch-update-pending", "--dept-id", "abc"},
{"graduate", "batch-update-pending", "--dept-id", "1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
{"graduate", "commit-restore"},
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
// ── group ────────────────────────────────────────────
{"group", "query-group-rule", "--offset", "abc"},
{"group", "query-group-rule", "--size", "abc"},
{"group", "query-preview-data", "--offset", "abc"},
{"group", "query-preview-data", "--size", "abc"},
{"group", "create-group-rule"},
{"group", "create-group-rule", "--name", "X"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
{"group", "delete-group-rule"},
{"group", "delete-group-rule", "--rule-id", "abc"},
{"group", "enable-group-rule"},
{"group", "enable-group-rule", "--rule-id", "abc"},
{"group", "disable-group-rule"},
{"group", "disable-group-rule", "--rule-id", "abc"},
}
for _, args := range cases {
root := newCollegeContactCommand()
if err := executeCommand(root, args...); err == nil {
t.Errorf("%v: expected non-nil error, got nil", args)
}
}
}
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
// user_required destructive leaf in a paired manner:
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
// productID, tool name, and complete argument payload.
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
type destructiveCase struct {
name string
args []string
wantTool string
wantInput map[string]any
}
cases := []destructiveCase{
{
"dept delete",
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
"delete_college_contact_dept",
map[string]any{"deptId": int64(123)},
},
{
"employee remove",
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
"remove_employee",
map[string]any{"staffIds": []string{"S1", "S2"}},
},
{
"alumni delete-dept",
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
"delete_alumni_dept",
map[string]any{"alumniDeptId": int64(1)},
},
{
"alumni remove-alumnus",
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
"delete_alumnus",
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
},
{
"alumni cancel-invite",
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
"delete_alumni_invite_record",
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"alumni disband-group",
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
"disband_alumni_group",
map[string]any{"alumniDeptId": int64(1)},
},
{
"graduate commit-graduate",
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
"commit_graduate",
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
},
{
"graduate all-graduate",
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
"all_graduate",
map[string]any{"graduateYear": int64(2026)},
},
{
"graduate batch-graduate",
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate delete-and-graduate",
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
"delete_and_graduate",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-delete-pending",
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
"batch_delete_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
},
{
"graduate batch-update-pending",
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
"batch_update_pending",
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
},
{
"graduate commit-restore",
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
"commit_restore",
map[string]any{"graduateDeptIds": []int64{1, 2}},
},
{
"group delete-group-rule",
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
"delete_group_rule",
map[string]any{"ruleId": int64(1)},
},
{
"group execute-group-rule",
[]string{"college-contact", "group", "execute-group-rule"},
"execute_group_rule",
map[string]any{},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newCollegeContactTestRoot()
argsWithYes := append(append([]string{}, tc.args...), "--yes")
root.SetArgs(argsWithYes)
err := root.Execute()
if err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "college-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
// verify the productID, tool name, and args passed to callMCPToolOnServer.
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
// TestCollegeContactDispatch verifies that representative commands from each
// group dispatch to the correct MCP tool with the expected productID and args.
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
type dispatchCase struct {
name string
args []string
wantTool string
wantProd string
checkArgs func(t *testing.T, args map[string]any)
}
cases := []dispatchCase{
{
name: "dept get-standard-structure",
args: []string{"college-contact", "dept", "get-standard-structure"},
wantTool: "get_college_standard_structure",
wantProd: "college-contact",
},
{
name: "dept get-detail",
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
wantTool: "get_college_dept_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
{
name: "dept create",
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
wantTool: "create_college_contact_dept",
wantProd: "college-contact",
},
{
name: "employee get-detail",
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
wantTool: "get_employee_detail",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["staffId"] != "S1" {
t.Errorf("staffId = %v, want S1", input["staffId"])
}
},
},
{
name: "alumni get-dept-tree",
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
wantTool: "get_alumni_dept_tree",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["alumniDeptId"] != int64(123) {
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
}
},
},
{
name: "graduate query-graduate-years",
args: []string{"college-contact", "graduate", "query-graduate-years"},
wantTool: "query_graduate_years",
wantProd: "college-contact",
},
{
name: "group query-group-rule",
args: []string{"college-contact", "group", "query-group-rule"},
wantTool: "query_group_rule",
wantProd: "college-contact",
},
{
name: "dept delete with --yes",
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
wantTool: "delete_college_contact_dept",
wantProd: "college-contact",
checkArgs: func(t *testing.T, args map[string]any) {
input := args["input"].(map[string]any)
if input["deptId"] != int64(123) {
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := withCollegeContactDispatchCaller(t)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newCollegeContactCommand())
root.SetArgs(tc.args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if caller.productID != tc.wantProd {
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
}
if caller.tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
}
if tc.checkArgs != nil {
tc.checkArgs(t, caller.args)
}
})
}
}
+3 -2
View File
@@ -39,8 +39,9 @@ func newConferenceCommand() *cobra.Command {
直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`,
RunE: runUnavailable,
}
newHybridGroupCommand(root)
meetingCmd := &cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}
meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE})
meetingCreateCmd := &cobra.Command{
Use: "reserve",
@@ -59,7 +60,7 @@ func newConferenceCommand() *cobra.Command {
root.AddCommand(meetingCmd)
// member 子命令组 — 成员管理
memberCmd := &cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE}
memberCmd := newGroupCommand(&cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE})
memberInviteCmd := &cobra.Command{
Use: "invite",
+17 -17
View File
@@ -422,7 +422,7 @@ func newContactCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "contact",
Short: "通讯录 / 用户 / 部门 / 角色 / 人员关系",
Long: `查询钉钉通讯录:用户搜索、手机号查找、部门搜索、子部门 / 成员列表、人员关系;用户花名册档案信息(学历、家庭、银行卡、合同等)与离职员工信息。
@@ -441,9 +441,9 @@ func newContactCommand() *cobra.Command {
- contact user profile fields/get: 员工花名册档案查询(学历、家庭、银行卡等)
- contact user dismission search: 离职员工列表查询`,
RunE: groupRunE,
}
})
userCmd := &cobra.Command{
userCmd := newGroupCommand(&cobra.Command{
Use: "user",
Short: "人员管理",
Long: `人员管理:通讯录用户查询、修改员工信息、邀请员工加入企业、用户档案(花名册)查询、离职员工查询。
@@ -457,7 +457,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
RunE: groupRunE,
}
})
contactUserGetSelfCmd := &cobra.Command{
Use: "get-self",
@@ -499,10 +499,10 @@ func newContactCommand() *cobra.Command {
},
})
relationCmd := &cobra.Command{Use: "relation",
relationCmd := newGroupCommand(&cobra.Command{Use: "relation",
Short: "人员关系查询",
Long: `查询钉钉人员关系:特别关注人。`,
RunE: groupRunE}
RunE: groupRunE})
contactRelationListMyFollowingsCmd := &cobra.Command{
Use: "list-my-followings",
@@ -704,7 +704,7 @@ func newContactCommand() *cobra.Command {
// ── label 角色 ──────────────────────────────────────────────────
contactLabelCmd := &cobra.Command{
contactLabelCmd := newGroupCommand(&cobra.Command{
Use: "label",
Aliases: []string{"role"},
Short: "角色查询",
@@ -720,7 +720,7 @@ func newContactCommand() *cobra.Command {
2. 从返回结果中匹配目标角色名称及 labelId
3. contact label list-members --id <labelId> → 获取该角色下的成员`,
RunE: groupRunE,
}
})
runContactLabelList := func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
@@ -796,7 +796,7 @@ func newContactCommand() *cobra.Command {
contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd)
contactDeptCmd := &cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE}
contactDeptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE})
contactDeptSearchCmd := &cobra.Command{
Use: "search",
@@ -988,7 +988,7 @@ func newContactCommand() *cobra.Command {
})
// ── user profile 用户档案(花名册) ────────────────────────────────────
contactUserProfileCmd := &cobra.Command{
contactUserProfileCmd := newGroupCommand(&cobra.Command{
Use: "profile",
Short: "用户档案(花名册)",
Long: `用户档案(花名册):查询花名册字段列表、查询员工花名册字段信息。
@@ -1000,7 +1000,7 @@ func newContactCommand() *cobra.Command {
- contact user get: 组织管理信息(部门、主管、管理员权限)
- contact user profile get: 个人档案信息(学历、家庭、银行卡等)`,
RunE: groupRunE,
}
})
contactUserProfileFieldsCmd := &cobra.Command{
Use: "fields",
@@ -1124,12 +1124,12 @@ contact user profile fields 获取可用字段列表。
contactUserProfileCmd.AddCommand(contactUserProfileFieldsCmd, contactUserProfileGetCmd)
// ── user dismission 离职员工 ───────────────────────────────────────────
contactUserDismissionCmd := &cobra.Command{
contactUserDismissionCmd := newGroupCommand(&cobra.Command{
Use: "dismission",
Short: "离职员工查询",
Long: `离职员工查询:分页获取离职员工列表,支持按员工姓名、离职时间范围、部门进行过滤。`,
RunE: groupRunE,
}
})
contactUserDismissionSearchCmd := &cobra.Command{
Use: "search",
@@ -1568,7 +1568,7 @@ contact user profile fields 获取可用字段列表。
// ── org 企业管理 ──────────────────────────────────────────────────
contactOrgCmd := &cobra.Command{
contactOrgCmd := newGroupCommand(&cobra.Command{
Use: "org",
Short: "企业管理",
Long: `企业管理:创建企业。
@@ -1578,7 +1578,7 @@ contact user profile fields 获取可用字段列表。
- 创建企业专属账号 → contact account create
- 邀请员工加入企业 → contact user invite`,
RunE: groupRunE,
}
})
contactOrgCreateCmd := &cobra.Command{
Use: "create",
@@ -1639,12 +1639,12 @@ contact user profile fields 获取可用字段列表。
// ── account 企业账号管理 ──────────────────────────────────────────
contactAccountCmd := &cobra.Command{
contactAccountCmd := newGroupCommand(&cobra.Command{
Use: "account",
Short: "企业账号管理",
Long: "企业账号管理:创建或更新企业专属账号。",
RunE: groupRunE,
}
})
contactAccountCreateCmd := &cobra.Command{
Use: "create",
@@ -1,9 +1,11 @@
package helpers
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -37,8 +39,11 @@ func TestCrossPlatformCoverageContactRemainingCompatibilityBranches(t *testing.T
if err := hint.RunE(hint, []string{"--help"}); err != nil {
t.Fatalf("hint help: %v", err)
}
if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") {
t.Fatalf("hint guidance err=%v", err)
err := hint.RunE(hint, []string{"unexpected"})
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "use: dws contact dept") {
t.Fatalf("hint guidance err=%#v", structured)
}
if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") {
-257
View File
@@ -1,257 +0,0 @@
package helpers
import (
"strings"
"unicode/utf8"
)
// splitMarkdownSafe splits content into chunks of at most `limit` runes each,
// respecting markdown structure boundaries.
//
// Split priority (high to low):
// 1. H1 headings (# )
// 2. H2 headings (## )
// 3. H3 headings (### )
// 4. Blank lines (paragraph boundaries)
// 5. Hard split (preserving table/code block integrity)
//
// Invariant: strings.Join(result, "") == content (no content loss)
func splitMarkdownSafe(content string, limit int) []string {
if utf8.RuneCountInString(content) <= limit {
return []string{content}
}
blocks := parseMarkdownBlocks(content)
return mergeBlocksIntoChunks(blocks, limit)
}
// markdownBlock represents an atomic block that should not be split.
type markdownBlock struct {
text string
blockType int
}
const (
blockNormal = 0
blockH1 = 1
blockH2 = 2
blockH3 = 3
blockTable = 4
blockCodeBlock = 5
)
// parseMarkdownBlocks splits content into atomic blocks that should be kept together.
// The invariant is: strings.Join(all block texts, "") == original content.
// Each block's text includes trailing newlines up to (but not including) the next block's start.
func parseMarkdownBlocks(content string) []markdownBlock {
content = strings.ReplaceAll(content, "\r\n", "\n")
lines := strings.Split(content, "\n")
var blocks []markdownBlock
var currentLines []string
currentType := blockNormal
inCodeBlock := false
flushCurrent := func(includeTrailingNewline bool) {
if len(currentLines) > 0 {
text := strings.Join(currentLines, "\n")
if includeTrailingNewline {
text += "\n"
}
blocks = append(blocks, markdownBlock{text: text, blockType: currentType})
currentLines = nil
currentType = blockNormal
}
}
for i, line := range lines {
trimmed := strings.TrimSpace(line)
isLastLine := i == len(lines)-1
// Code block fence detection
if strings.HasPrefix(trimmed, "```") {
if !inCodeBlock {
flushCurrent(!isLastLine)
currentType = blockCodeBlock
inCodeBlock = true
currentLines = append(currentLines, line)
continue
}
// End of code block
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
inCodeBlock = false
continue
}
if inCodeBlock {
currentLines = append(currentLines, line)
continue
}
// Table line detection
if strings.HasPrefix(trimmed, "|") {
if currentType != blockTable {
flushCurrent(!isLastLine)
currentType = blockTable
}
currentLines = append(currentLines, line)
continue
}
// If we were in a table and hit a non-table line, flush
if currentType == blockTable {
flushCurrent(!isLastLine)
}
// Heading detection — only at line start (not inside other blocks)
// Order matters: check H3 before H2 before H1 to avoid ambiguity
if strings.HasPrefix(line, "### ") {
flushCurrent(!isLastLine)
currentType = blockH3
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
continue
} else if strings.HasPrefix(line, "## ") {
flushCurrent(!isLastLine)
currentType = blockH2
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
continue
}
if strings.HasPrefix(line, "# ") && !strings.HasPrefix(line, "## ") {
flushCurrent(!isLastLine)
currentType = blockH1
currentLines = append(currentLines, line)
flushCurrent(!isLastLine)
continue
}
currentLines = append(currentLines, line)
}
// Last block: no trailing newline
flushCurrent(false)
return blocks
}
// mergeBlocksIntoChunks greedily fills chunks up to the limit, then splits
// backwards at the nearest heading boundary. This ensures chunks are as large
// as possible while still breaking at meaningful markdown structure points.
//
// Strategy: fill forward until adding the next block would exceed the limit,
// then look backwards for the last heading in the current chunk to split there.
// If no heading is found, split at the overflow point (greedy).
func mergeBlocksIntoChunks(blocks []markdownBlock, limit int) []string {
var chunks []string
i := 0
for i < len(blocks) {
// Accumulate blocks greedily until we'd exceed the limit
var chunkBlocks []markdownBlock
chunkRunes := 0
for i < len(blocks) {
blockRunes := utf8.RuneCountInString(blocks[i].text)
// Single oversized block: hard-split it
if blockRunes > limit && chunkRunes == 0 {
subChunks := hardSplitBlock(blocks[i].text, limit)
chunks = append(chunks, subChunks...)
i++
chunkBlocks = nil
chunkRunes = 0
continue
}
// Would exceed limit: stop accumulating
if chunkRunes+blockRunes > limit && chunkRunes > 0 {
break
}
chunkBlocks = append(chunkBlocks, blocks[i])
chunkRunes += blockRunes
i++
}
if len(chunkBlocks) == 0 {
continue
}
// If we stopped because of overflow AND there are multiple blocks,
// look backwards for the last heading to use as a split point
if i < len(blocks) && len(chunkBlocks) > 1 {
splitIdx := -1
for j := len(chunkBlocks) - 1; j > 0; j-- {
bt := chunkBlocks[j].blockType
if bt == blockH1 || bt == blockH2 || bt == blockH3 {
splitIdx = j
break
}
}
if splitIdx > 0 {
// Split: emit blocks before the heading, push heading+ back
var emitBuilder strings.Builder
for _, b := range chunkBlocks[:splitIdx] {
emitBuilder.WriteString(b.text)
}
chunks = append(chunks, emitBuilder.String())
// Rewind: put the heading and subsequent blocks back for next iteration
i -= len(chunkBlocks) - splitIdx
continue
}
}
// No heading split point found (or single block): emit all accumulated blocks
var emitBuilder strings.Builder
for _, b := range chunkBlocks {
emitBuilder.WriteString(b.text)
}
chunks = append(chunks, emitBuilder.String())
}
return chunks
}
// hardSplitBlock splits a single oversized block at paragraph boundaries,
// falling back to rune-level splitting.
func hardSplitBlock(text string, limit int) []string {
// SplitAfter keeps the paragraph separator attached to the preceding
// paragraph. The previous Split implementation rebuilt separators while
// merging, but dropped them whenever a chunk boundary fell between two
// paragraphs, violating the no-content-loss invariant.
paragraphs := strings.SplitAfter(text, "\n\n")
var chunks []string
var current strings.Builder
currentRunes := 0
for _, para := range paragraphs {
paraRunes := utf8.RuneCountInString(para)
if currentRunes+paraRunes > limit && currentRunes > 0 {
chunks = append(chunks, current.String())
current.Reset()
currentRunes = 0
}
// If single paragraph exceeds limit, split by runes
if paraRunes > limit {
runes := []rune(para)
for start := 0; start < len(runes); start += limit {
end := start + limit
if end > len(runes) {
end = len(runes)
}
chunks = append(chunks, string(runes[start:end]))
}
continue
}
current.WriteString(para)
currentRunes += paraRunes
}
if currentRunes > 0 {
chunks = append(chunks, current.String())
}
return chunks
}
-125
View File
@@ -1,125 +0,0 @@
package helpers
import (
"context"
"strings"
"testing"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageSplitMarkdownSafePreservesContentAndLimitsChunks(t *testing.T) {
short := "short 文本"
if got := splitMarkdownSafe(short, 100); len(got) != 1 || got[0] != short {
t.Fatalf("short split = %#v", got)
}
content := "# Heading\r\nparagraph one\r\n\r\n## Two\r\n| a | b |\r\n|---|---|\r\n| 1 | 2 |\r\nnormal\r\n### Three\r\n```go\r\nfmt.Println(\"hello\")\r\n```\r\ntail"
normalized := strings.ReplaceAll(content, "\r\n", "\n")
chunks := splitMarkdownSafe(content, 35)
if strings.Join(chunks, "") != normalized {
t.Fatalf("split content was not preserved:\nwant %q\n got %q", normalized, strings.Join(chunks, ""))
}
for _, chunk := range chunks {
if utf8.RuneCountInString(chunk) > 35 {
t.Errorf("chunk exceeds limit: %d %q", utf8.RuneCountInString(chunk), chunk)
}
}
blocks := parseMarkdownBlocks("before\n```\nunclosed")
if len(blocks) < 2 || blocks[len(blocks)-1].blockType != blockCodeBlock {
t.Fatalf("unclosed code blocks = %#v", blocks)
}
}
func TestCrossPlatformCoverageMergeBlocksUsesHeadingsAndHardSplits(t *testing.T) {
blocks := []markdownBlock{
{text: "aaaa", blockType: blockNormal},
{text: "# h\n", blockType: blockH1},
{text: "bbbb", blockType: blockNormal},
{text: "cccc", blockType: blockNormal},
}
chunks := mergeBlocksIntoChunks(blocks, 9)
if strings.Join(chunks, "") != "aaaa# h\nbbbbcccc" || len(chunks) < 2 {
t.Fatalf("heading merge = %#v", chunks)
}
chunks = mergeBlocksIntoChunks([]markdownBlock{{text: "aaaa"}, {text: "bbbb"}, {text: "cccc"}}, 8)
if strings.Join(chunks, "") != "aaaabbbbcccc" {
t.Fatalf("greedy merge = %#v", chunks)
}
oversized := "one\n\ntwo\n\n" + strings.Repeat("界", 11)
chunks = mergeBlocksIntoChunks([]markdownBlock{{text: oversized}}, 5)
if strings.Join(chunks, "") != oversized {
t.Fatalf("oversized merge = %#v", chunks)
}
for _, chunk := range chunks {
if utf8.RuneCountInString(chunk) > 5 {
t.Errorf("hard-split chunk exceeds limit: %q", chunk)
}
}
if got := mergeBlocksIntoChunks(nil, 5); len(got) != 0 {
t.Fatalf("empty merge = %#v", got)
}
}
func TestCrossPlatformCoverageHardSplitBlockCoversParagraphAndRuneBoundaries(t *testing.T) {
for _, text := range []string{
"aa\n\nbb\n\ncc",
"aa\n\n" + strings.Repeat("x", 12),
strings.Repeat("界", 13),
"",
} {
chunks := hardSplitBlock(text, 5)
if strings.Join(chunks, "") != text {
t.Errorf("hardSplitBlock(%q) = %#v", text, chunks)
}
for _, chunk := range chunks {
if utf8.RuneCountInString(chunk) > 5 {
t.Errorf("chunk exceeds limit: %q", chunk)
}
}
}
}
func TestCrossPlatformCoverageRuntimeDefaultsRegistryValidationAndSnapshot(t *testing.T) {
runtimeDefaultsMu.Lock()
previous := runtimeDefaults
runtimeDefaults = make(map[string]edition.RuntimeDefaultFn)
runtimeDefaultsMu.Unlock()
t.Cleanup(func() {
runtimeDefaultsMu.Lock()
runtimeDefaults = previous
runtimeDefaultsMu.Unlock()
})
resolver := func(context.Context) (string, bool) { return "value", true }
RegisterRuntimeDefault("$value", resolver)
snapshot := RuntimeDefaultsSnapshot()
if len(snapshot) != 1 || snapshot["$value"] == nil {
t.Fatalf("RuntimeDefaultsSnapshot() = %#v", snapshot)
}
delete(snapshot, "$value")
if len(RuntimeDefaultsSnapshot()) != 1 {
t.Fatal("snapshot mutated the runtime registry")
}
for _, tc := range []struct {
name string
fn edition.RuntimeDefaultFn
}{
{"", resolver}, {"$nil", nil}, {"$value", resolver},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("RegisterRuntimeDefault(%q) did not panic", tc.name)
}
}()
RegisterRuntimeDefault(tc.name, tc.fn)
}()
}
}
+3 -27
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -30,30 +30,6 @@ func TestCrossPlatformCoveragePureScalarAndCommandHelpersCoverage(t *testing.T)
_ = isNumericUserID(value)
}
root := &cobra.Command{Use: "calendar"}
known := &cobra.Command{Use: "event", Aliases: []string{"e"}}
hidden := &cobra.Command{Use: "hidden", Hidden: true}
root.AddCommand(known, hidden)
for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} {
_ = findUnknownVerb(root, args)
}
printUnknownSubcmdError(root, "evnt")
for _, depth := range []int{0, 1, 3} {
_ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth)
}
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
root.Flags().StringP("known", "k", "", "")
for _, args := range [][]string{
{"dws", "calendar", "--known=x"},
{"dws", "calendar", "--unknown"},
{"dws", "calendar", "-z"},
{"dws", "calendar", "--", "--ignored"},
} {
os.Args = args
_ = findUnknownFlag(root)
}
for _, event := range []any{
nil,
map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}},
@@ -284,7 +260,7 @@ func TestCrossPlatformCoverageSmallHandlerAndFormatterCoverage(t *testing.T) {
group := &cobra.Command{Use: "range"}
group.AddCommand(&cobra.Command{Use: "read"})
parent.AddCommand(group)
_ = deepSuggestSubcommand(parent, "read")
_ = deepSuggestSubcommand(parent, "missing")
_ = cmdutil.SuggestDescendantSubcommands(parent, "read")
_ = cmdutil.SuggestDescendantSubcommands(parent, "missing")
_ = time.Now()
}
+2 -3
View File
@@ -16,7 +16,6 @@ package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -71,7 +70,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(root)
newGroupCommand(root)
doc := &cobra.Command{
Use: "doc",
@@ -83,7 +82,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(doc)
newGroupCommand(doc)
doc.AddCommand(newDevDocSearchCommand(runner))
root.AddCommand(
+9 -10
View File
@@ -23,7 +23,6 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -140,7 +139,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(root)
newGroupCommand(root)
webapp := &cobra.Command{
Use: "webapp",
@@ -152,7 +151,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(webapp)
newGroupCommand(webapp)
webapp.AddCommand(
newDevAppWebappGetCommand(runner),
newDevAppWebappConfigCommand(runner),
@@ -168,7 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(permission)
newGroupCommand(permission)
permission.AddCommand(
newDevAppPermissionListCommand(runner),
newDevAppPermissionAddCommand(runner),
@@ -185,7 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(credentials)
newGroupCommand(credentials)
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
member := &cobra.Command{
@@ -198,7 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(member)
newGroupCommand(member)
member.AddCommand(
newDevAppMemberListCommand(runner),
newDevAppMemberAddCommand(runner),
@@ -215,7 +214,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(security)
newGroupCommand(security)
security.AddCommand(newDevAppSecurityConfigCommand(runner))
robot := &cobra.Command{
@@ -228,7 +227,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(robot)
newGroupCommand(robot)
robot.AddCommand(
newDevAppRobotSubmitCommand(runner),
newDevAppRobotResultCommand(runner),
@@ -248,7 +247,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(version)
newGroupCommand(version)
version.AddCommand(
newDevAppVersionCreateCommand(runner),
newDevAppVersionListCommand(runner),
@@ -268,7 +267,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
cmdutil.MarkGroup(event)
newGroupCommand(event)
event.AddCommand(
newDevAppEventListCommand(runner),
newDevAppEventSubscribeCommand(runner),
+1
View File
@@ -587,6 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
newDevAppRobotConnectRestartCommand(),
newDevAppRobotConnectListCommand(runner),
)
newHybridGroupCommand(cmd)
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
+3 -3
View File
@@ -24,14 +24,14 @@ func newDevdocCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "devdoc",
Short: "开放平台文档搜索",
Long: `搜索钉钉开放平台开发文档。默认以表格格式输出(标题、URL),使用 -f json 获取原始 JSON。`,
RunE: groupRunE,
}
})
articleCmd := &cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE}
articleCmd := newGroupCommand(&cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE})
articleCmd.AddCommand(newDevdocArticleSearchCommand())
root.AddCommand(articleCmd)
root.AddCommand(hintSubCmd("search", "use: dws devdoc article search --query <关键词>"))
+3 -3
View File
@@ -42,14 +42,14 @@ func newDingCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "ding",
Short: "DING 消息 / 发送 / 撤回",
Long: `发送和撤回 DING 消息(应用内/短信/电话)。预发环境可用。`,
RunE: groupRunE,
}
})
dingMessageCmd := &cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE}
dingMessageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE})
dingMessageSendCmd := &cobra.Command{
Use: "send",
+28 -21
View File
@@ -1166,7 +1166,7 @@ func newDocCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "doc",
Short: "钉钉文档管理",
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
@@ -1190,7 +1190,7 @@ func newDocCommand() *cobra.Command {
文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`,
RunE: groupRunE,
}
})
searchCmd := &cobra.Command{
Use: "search",
@@ -1826,7 +1826,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}
fileCmd := newGroupCommand(&cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE})
fileCreateCmd := &cobra.Command{
Use: "create",
@@ -1904,7 +1904,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE})
folderCreateCmd := &cobra.Command{
Use: "create",
@@ -2063,12 +2063,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
})
blockCmd := &cobra.Command{
blockCmd := newGroupCommand(&cobra.Command{
Use: "block",
Short: "块级编辑",
Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`,
RunE: groupRunE,
}
})
blockListCmd := &cobra.Command{
Use: "list",
@@ -2844,12 +2844,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
_ = renameCmd.Flags().MarkHidden("title")
// ── media (文档媒体/附件) ────────────────────────────────
mediaCmd := &cobra.Command{
mediaCmd := newGroupCommand(&cobra.Command{
Use: "media",
Short: "文档媒体 / 附件管理",
Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`,
RunE: groupRunE,
}
})
mediaDownloadCmd := &cobra.Command{
Use: "download",
@@ -3036,12 +3036,12 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "文档评论 / 评论管理",
Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`,
RunE: groupRunE,
}
})
commentListCmd := &cobra.Command{
Use: "list",
@@ -3545,13 +3545,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
// ── permission (文档协作权限) ────────────────────────────
permissionCmd := &cobra.Command{
permissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档协作权限管理",
Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`,
RunE: groupRunE,
}
})
permissionAddCmd := &cobra.Command{
Use: "add",
@@ -4260,6 +4260,7 @@ CLI 内部自动完成全部流程:
_ = exportCmd.Flags().MarkHidden("file-id")
exportCmd.AddCommand(exportGetCmd)
newHybridGroupCommand(exportCmd)
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
importCmd := &cobra.Command{
@@ -4284,8 +4285,8 @@ CLI 内部自动完成全部流程:
3. 确认导入(触发格式转换)
4. 渐进式退避轮询等待完成(最多约 5 分钟)
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
dws doc import get --task-id <taskId>`,
如果轮询超时或中断,会输出包含原目标的完整命令供后续手动查询,例如:
dws doc import get --task-id <taskId> --workspace <原目标WORKSPACE_ID>`,
Example: ` # 导入 Word 文档
dws doc import --file ./report.docx
@@ -4315,13 +4316,16 @@ CLI 内部自动完成全部流程:
Short: "查询导入任务结果(手动兜底)",
Long: `根据 taskId 查询文档导入任务的执行结果。
通常不需要手动调用,dws doc import 会自动完成轮询。
仅在导入命令超时或中断后,用于手动查询任务状态。
仅在导入命令超时或中断后,用于手动查询任务状态。建议直接复制导入结果
中的完整 next_command;其中携带的原目标(--folder 或 --workspace)用于在
completed 后回读验证真实落点。只传 taskId 仍可查询 processing/failed,
但 completed 时会返回未验证错误,不会误报成功。
任务状态:
processing 转换中
completed 导入成功,返回 documentUrl
failed 导入失败`,
Example: ` dws doc import get --task-id <TASK_ID>`,
Example: ` dws doc import get --task-id <TASK_ID> --workspace <WORKSPACE_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runImportGetCommand(cmd, docImportFlowConfig())
},
@@ -4348,22 +4352,25 @@ CLI 内部自动完成全部流程:
},
Selection: contract.SelectionSpec{
AgentSummary: "根据 taskId 查询文档导入任务的执行结果",
UseWhen: []string{"查询文档导入任务结果(已有 taskId,导入超时/中断后兜底)时"},
UseWhen: []string{"已有 doc import 超时或中断结果及其完整 next_command,需要续查同一 taskId 并验证原 folder/workspace 落点时"},
AvoidWhen: []string{"发起导入用 doc import(若入口可用);不要用本命令代替导入"},
Examples: []string{"dws doc import get --task-id <TASK_ID> --format json"},
Examples: []string{"dws doc import get --task-id <TASK_ID> --workspace <WORKSPACE_ID> --format json"},
},
},
})
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
importGetCmd.Flags().String("folder", "", "原导入目标文件夹 ID 或 URL(completed 后落点验证需要)")
importGetCmd.Flags().String("workspace", "", "原导入目标知识库 ID 或 URL(completed 后落点验证需要)")
importCmd.AddCommand(importGetCmd)
newHybridGroupCommand(importCmd)
// ── doc version 子命令组 ──
versionCmd := &cobra.Command{
versionCmd := newGroupCommand(&cobra.Command{
Use: "version",
Short: "文档历史版本管理",
Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
RunE: groupRunE,
}
})
versionSaveCmd := &cobra.Command{
Use: "save",
@@ -4549,7 +4556,7 @@ CLI 内部自动完成全部流程:
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
// ── template 子命令组 ──────────────────────────────────────────────────────
templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE})
templateListCmd := &cobra.Command{
Use: "list",
+1 -1
View File
@@ -683,7 +683,7 @@ func TestCrossPlatformCoverageDocExportImportCommandEdges(t *testing.T) {
{"failed empty", []scriptedToolStep{{text: `{"status":"failed"}`}}, false},
} {
t.Run("import get "+tc.name, func(t *testing.T) {
_ = run(t, tc.steps, tc.dry, "import", "get", "--task-id=task")
_ = run(t, tc.steps, tc.dry, "import", "get", "--task-id=task", "--workspace=workspace")
})
}
}
+457
View File
@@ -0,0 +1,457 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func fastDocImportConfig() importFlowConfig {
cfg := docImportFlowConfig()
cfg.poll.maxPolls = 2
cfg.poll.interval = func(int) time.Duration { return 0 }
cfg.poll.wait = func(context.Context, time.Duration) error { return nil }
return cfg
}
func executeDocImportCommand(t *testing.T, caller *sheetImportCaller, cfg importFlowConfig, args ...string) (string, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
SetHTTPPutFile(nil)
})
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
os.Args = []string{"dws", "doc"}
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
command := &cobra.Command{
Use: "import",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, positional []string) error {
return runImportCommand(cmd, positional, cfg)
},
}
command.Flags().String("file", "", "")
command.Flags().String("folder", "", "")
command.Flags().String("folder-id", "", "")
command.Flags().String("workspace", "", "")
command.Flags().String("workspace-id", "", "")
command.Flags().String("name", "", "")
command.SetArgs(args)
err := command.Execute()
return output.String(), err
}
func TestCrossPlatformCoverageDocImportDefaultTargetIsResolvedAndVerified(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"my-space","name":"我的文档"}]}}`},
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1","documentName":"sales","documentType":"ALIDOC"}`},
"get_document_info": {`{"result":{"nodeId":"node-1","workspaceId":"my-space","folderId":"root-folder","name":"sales","contentType":"ALIDOC"}}`},
}}
output, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath)
if err != nil {
t.Fatalf("doc import: %v\n%s", err, output)
}
if len(caller.calls) != 5 {
t.Fatalf("calls = %#v, want 5", caller.calls)
}
wantTools := []string{"list_wikiSpaces", "create_import_session", "confirm_import", "query_import_task", "get_document_info"}
for index, want := range wantTools {
if got := caller.calls[index].tool; got != want {
t.Fatalf("call[%d] tool = %q, want %q", index, got, want)
}
}
if got := caller.calls[1].args["workspaceId"]; got != "my-space" {
t.Fatalf("create_import_session workspaceId = %#v", got)
}
var result map[string]any
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("decode output: %v\n%s", err, output)
}
if result["nodeId"] != "node-1" || result["verified"] != true {
t.Fatalf("result = %#v", result)
}
target, _ := result["target"].(map[string]any)
if target["source"] != "default_personal_workspace" || target["workspaceId"] != "my-space" {
t.Fatalf("target = %#v", target)
}
}
func TestDocImportExplicitFolderSkipsDefaultResolution(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1"}`},
"get_document_info": {`{"nodeId":"node-1","folderId":"folder-1","workspaceId":"space-1"}`},
}}
output, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath, "--folder", "folder-1")
if err != nil {
t.Fatalf("doc import: %v\n%s", err, output)
}
for _, call := range caller.calls {
if call.tool == "list_wikiSpaces" {
t.Fatalf("explicit target unexpectedly resolved default: %#v", caller.calls)
}
}
if got := caller.calls[0].args["targetFolderId"]; got != "folder-1" {
t.Fatalf("targetFolderId = %#v", got)
}
}
func TestDocImportDefaultResolutionFailureIsNotStarted(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"list_wikiSpaces": {`{"result":{"wikiSpaces":[]}}`},
}}
_, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath)
if err == nil {
t.Fatal("expected resolution failure")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Reason != "doc_import_default_target_unavailable" || structured.FailureStage != "resolve_default_target" {
t.Fatalf("structured error = %#v", structured)
}
if structured.ExecutionStarted == nil || *structured.ExecutionStarted {
t.Fatalf("ExecutionStarted = %#v, want false", structured.ExecutionStarted)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "list_wikiSpaces" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportCancellationReturnsExecutableRecoveryCommand(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"list_wikiSpaces": {`{"result":{"wikiSpaces":[{"workspaceId":"my-space","name":"我的文档"}]}}`},
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
}}
cfg := fastDocImportConfig()
cfg.poll.wait = func(context.Context, time.Duration) error { return context.Canceled }
_, err := executeDocImportCommand(t, caller, cfg, "--file", filePath)
if err == nil {
t.Fatal("expected cancellation error")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v, want errors.Is(context.Canceled)", err)
}
for _, want := range []string{
"导入轮询被取消",
"dws doc import get --task-id task-1 --workspace my-space",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("cancellation error = %q, want %q", err, want)
}
}
if len(caller.calls) != 3 || caller.calls[0].tool != "list_wikiSpaces" || caller.calls[2].tool != "confirm_import" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportPlacementMismatchIsPartialSuccess(t *testing.T) {
filePath := writeImportFixture(t, "md")
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.test/file"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1"}`},
"get_document_info": {`{"nodeId":"node-1","folderId":"wrong-folder"}`},
}}
_, err := executeDocImportCommand(t, caller, fastDocImportConfig(), "--file", filePath, "--folder", "folder-1")
if err == nil {
t.Fatal("expected placement verification failure")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Reason != "doc_import_placement_unverified" || structured.Details["status"] != "partial_success" {
t.Fatalf("structured error = %#v", structured)
}
if structured.ExecutionStarted == nil || !*structured.ExecutionStarted {
t.Fatalf("ExecutionStarted = %#v, want true", structured.ExecutionStarted)
}
}
func TestCrossPlatformCoverageParsePersonalDocWorkspaceIDRejectsAmbiguousResponse(t *testing.T) {
for _, text := range []string{
`{`,
`{"wikiSpaces":[]}`,
`{"wikiSpaces":"not-a-list"}`,
`{"wikiSpaces":[1]}`,
`{"wikiSpaces":[{"workspaceId":"a"},{"workspaceId":"b"}]}`,
`{"wikiSpaces":[{"name":"我的文档"}]}`,
} {
if _, err := parsePersonalDocWorkspaceID(text); err == nil {
t.Fatalf("parsePersonalDocWorkspaceID(%s) unexpectedly succeeded", text)
}
}
}
func TestCrossPlatformCoverageDocImportTargetDefensiveBranches(t *testing.T) {
if err := resolveDefaultDocImportTarget(context.Background(), nil); err != nil {
t.Fatalf("nil import target: %v", err)
}
for _, file := range []*preparedImportFile{{folder: "folder-1"}, {workspace: "space-1"}} {
if err := resolveDefaultDocImportTarget(context.Background(), file); err != nil {
t.Fatalf("explicit import target: %v", err)
}
}
for _, text := range []string{`{`, `{}`, `[]`} {
if _, err := parseImportedDocumentInfo(text); err == nil {
t.Fatalf("parseImportedDocumentInfo(%q) unexpectedly succeeded", text)
}
}
if info, err := parseImportedDocumentInfo(`{"data":{"document":{"nodeId":"node-1"}}}`); err != nil || info["nodeId"] != "node-1" {
t.Fatalf("nested document info = %#v, %v", info, err)
}
for _, test := range []struct {
raw string
want string
}{
{"https://alidocs.dingtalk.com/i/nodes/n?workspaceId=space-query", "space-query"},
{"https://alidocs.dingtalk.com/i/nodes/node-path", "node-path"},
{"https://alidocs.dingtalk.com/i/spaces/space-path", "space-path"},
{"https://alidocs.dingtalk.com/i/folders/folder-path", "folder-path"},
{"https://alidocs.dingtalk.com/unknown/path", "https://alidocs.dingtalk.com/unknown/path"},
{"plain-id", "plain-id"},
} {
if got := canonicalImportTargetID(test.raw); got != test.want {
t.Fatalf("canonicalImportTargetID(%q) = %q, want %q", test.raw, got, test.want)
}
}
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
verifyFailure := func(t *testing.T, file preparedImportFile, response, documentURL string) {
t.Helper()
responses := map[string][]string{}
if response != "" {
responses["get_document_info"] = []string{response}
}
InitDeps(&sheetImportCaller{responses: responses})
if _, _, err := verifyImportedDocumentPlacement(context.Background(), file, "task-1", documentURL); err == nil {
t.Fatal("placement verification unexpectedly succeeded")
}
}
verifyFailure(t, preparedImportFile{}, "", "")
verifyFailure(t, preparedImportFile{}, "", "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{}, `{`, "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{}, `{}`, "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{}, `{"nodeId":"other"}`, "https://alidocs.dingtalk.com/i/nodes/node-1")
verifyFailure(t, preparedImportFile{workspace: "space-1"}, `{"nodeId":"node-1","workspaceId":"wrong"}`, "https://alidocs.dingtalk.com/i/nodes/node-1")
}
func TestCrossPlatformCoverageDocImportGetVerifiesOriginalTarget(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2"}`},
"get_document_info": {`{"result":{"nodeId":"node-2","workspaceId":"space-2","name":"report"}}`},
}}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "space-2", "")
if err := runImportGetCommand(cmd, docImportFlowConfig()); err != nil {
t.Fatalf("doc import get: %v", err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "query_import_task" || caller.calls[1].tool != "get_document_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var result map[string]any
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if result["nodeId"] != "node-2" || result["verified"] != true {
t.Fatalf("result = %#v", result)
}
target, _ := result["target"].(map[string]any)
if target["source"] != "workspace_flag" || target["workspaceId"] != "space-2" {
t.Fatalf("target = %#v", target)
}
}
func TestCrossPlatformCoverageDocImportGetTaskIDOnlyQueriesProcessing(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"processing","taskId":"task-2"}`},
}}
if err := runDocCoverageCommand(t, caller, "import", "get", "--task-id=task-2"); err != nil {
t.Fatalf("taskId-only processing query: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "query_import_task" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportGetCompletedWithoutTargetIsUnverified(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2"}`},
}}
InitDeps(caller)
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "", "")
err := runImportGetCommand(cmd, docImportFlowConfig())
if err == nil {
t.Fatal("completed task without verification target unexpectedly succeeded")
}
var structured *apperrors.Error
if !errors.As(err, &structured) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if structured.Reason != "doc_import_verification_target_required" || structured.Details["taskStatus"] != "completed" || structured.Details["verified"] != false || structured.Details["nodeId"] != "node-2" {
t.Fatalf("structured error = %#v", structured)
}
if structured.ExecutionStarted == nil || !*structured.ExecutionStarted {
t.Fatalf("ExecutionStarted = %#v, want true", structured.ExecutionStarted)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "query_import_task" {
t.Fatalf("calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageDocImportGetInvalidJSONFailsClosed(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
os.Args = []string{"dws", "doc"}
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`not-json`},
}}
InitDeps(caller)
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "space-2", "")
err := runImportGetCommand(cmd, docImportFlowConfig())
if err == nil {
t.Fatal("invalid query response unexpectedly succeeded")
}
for _, want := range []string{
"解析导入任务响应失败",
"dws doc import get --task-id task-2 --workspace space-2",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want %q", err, want)
}
}
}
func TestCrossPlatformCoverageDocImportGetDryRunIncludesTarget(t *testing.T) {
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
caller := &sheetImportCaller{dryRun: true}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-2", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "space-2", "")
if err := runImportGetCommand(cmd, docImportFlowConfig()); err != nil {
t.Fatalf("doc import get dry-run: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run reached MCP: %#v", caller.calls)
}
var result map[string]any
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if result["dry_run"] != true || result["executed"] != false {
t.Fatalf("result = %#v", result)
}
target, _ := result["target"].(map[string]any)
if target["source"] != "workspace_flag" || target["workspaceId"] != "space-2" {
t.Fatalf("target = %#v", target)
}
}
func TestCrossPlatformCoverageDocImportRecoveryCommandCarriesEveryTarget(t *testing.T) {
got := importRecoveryCommand(docImportFlowConfig(), "task-1", preparedImportFile{
folder: "folder;unsafe", workspace: "https://alidocs.test/space?id=1&kind=doc",
})
for _, want := range []string{
"dws doc import get --task-id task-1",
"--folder 'folder;unsafe'",
"--workspace 'https://alidocs.test/space?id=1&kind=doc'",
} {
if !strings.Contains(got, want) {
t.Fatalf("recovery command = %q, want %q", got, want)
}
}
}
+15
View File
@@ -251,6 +251,14 @@ func prepareJsonMLBody(cmd *cobra.Command, raw string) (string, error) {
return cleaned, nil
}
// PrepareDocJSONMLBody exposes the shared strict JSONML write pipeline to
// higher-level Doc commands. It validates a complete document body and
// requires a root node; optional repair remains controlled by the caller's
// --fix-jsonml flag when that flag exists.
func PrepareDocJSONMLBody(cmd *cobra.Command, raw string) (string, error) {
return prepareJsonMLBody(cmd, raw)
}
// prepareJsonMLNode processes the JSONML array passed to `doc block
// insert/update --element` (a single JSONML block node, not a body).
//
@@ -343,6 +351,13 @@ func prepareJsonMLNode(cmd *cobra.Command, rawElement string) (string, error) {
return stripInputUnsafeChars(string(out)), nil
}
// PrepareDocJSONMLNode exposes the shared strict JSONML write pipeline for a
// single block node. Unlike PrepareDocJSONMLBody, it does not require a root
// document node.
func PrepareDocJSONMLNode(cmd *cobra.Command, raw string) (string, error) {
return prepareJsonMLNode(cmd, raw)
}
func emitFixNotes(notes []string) {
if len(notes) == 0 {
return
+2 -2
View File
@@ -76,7 +76,7 @@ func TestCrossPlatformCoverageJSONMLInputSanitizingAndShapeCoercion(t *testing.T
func TestCrossPlatformCoveragePrepareJSONMLBody(t *testing.T) {
strict := jsonMLTestCommand(t, false)
valid := `{"jsonml":["root",{},["p",{},["span",{},"ok"]]]}`
if got, err := prepareJsonMLBody(strict, valid); err != nil || !strings.Contains(got, `"root"`) {
if got, err := PrepareDocJSONMLBody(strict, valid); err != nil || !strings.Contains(got, `"root"`) {
t.Fatalf("valid wrapper = %q, %v", got, err)
}
if got, err := prepareJsonMLBody(strict, `["root",{},["p",{},["span",{},"ok"]]]`); err != nil || !strings.Contains(got, `"root"`) {
@@ -113,7 +113,7 @@ func TestCrossPlatformCoveragePrepareJSONMLNode(t *testing.T) {
`["p",{},["span",{},"ok"]]`,
`{"jsonml":[["p",{},["span",{},"ok"]]]}`,
} {
if got, err := prepareJsonMLNode(strict, raw); err != nil || !strings.Contains(got, `"p"`) {
if got, err := PrepareDocJSONMLNode(strict, raw); err != nil || !strings.Contains(got, `"p"`) {
t.Errorf("prepareJsonMLNode(%q) = %q, %v", raw, got, err)
}
}
+6 -6
View File
@@ -20,7 +20,7 @@ const docStyleGetToolName = "get_document_style"
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
func newDocStyleCommand() *cobra.Command {
styleCmd := &cobra.Command{
styleCmd := newGroupCommand(&cobra.Command{
Use: "style",
Short: "文档样式配置 (封面/背景)",
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
@@ -34,13 +34,13 @@ func newDocStyleCommand() *cobra.Command {
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
RunE: groupRunE,
}
})
coverCmd := &cobra.Command{
coverCmd := newGroupCommand(&cobra.Command{
Use: "cover",
Short: "文档封面设置/移除",
RunE: groupRunE,
}
})
coverSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档封面",
@@ -110,11 +110,11 @@ func newDocStyleCommand() *cobra.Command {
},
})
backgroundCmd := &cobra.Command{
backgroundCmd := newGroupCommand(&cobra.Command{
Use: "background",
Short: "文档背景设置/清除",
RunE: groupRunE,
}
})
backgroundSetCmd := &cobra.Command{
Use: "set",
Short: "设置文档背景纯色",
+2 -2
View File
@@ -207,12 +207,12 @@ func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
})
insertCmd := &cobra.Command{
Use: "insert",
+102 -59
View File
@@ -14,9 +14,12 @@ import (
)
const (
// initialChunkSize is the first attempted chunk size (rune count).
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
initialChunkSize = 10000
// DefaultMarkdownChunkRunes is the single source of truth for the markdown
// append-mode chunk limit (rune count), shared by every write path that
// chunks. The splitter budgets any injected repair (a re-emitted table
// header, a reopened fence) against this limit, so a repaired chunk is still
// guaranteed to be at most this many runes.
DefaultMarkdownChunkRunes = 30000
// longContentWarningThreshold triggers a hint to use --content-file.
longContentWarningThreshold = 2048
@@ -49,10 +52,22 @@ func detectContentSource(cmd *cobra.Command) contentInputSource {
// DocWriteResult is the structured output of the write pipeline.
type DocWriteResult struct {
Success bool `json:"success"`
NodeID string `json:"nodeId"`
ChunksWritten int `json:"chunksWritten"`
ServerResponse json.RawMessage `json:"serverResponse,omitempty"`
Success bool `json:"success"`
NodeID string `json:"nodeId"`
ChunksWritten int `json:"chunksWritten"`
// Degradations lists the chunk boundaries that changed the rendered
// structure. Empty means the document reads exactly as the input did.
Degradations []MarkdownDegradation `json:"degradations,omitempty"`
ServerResponse json.RawMessage `json:"serverResponse,omitempty"`
}
// chunkedWriteOutcome is what a chunked write reports back. It is a struct rather
// than another return value because the tuple was already four wide.
type chunkedWriteOutcome struct {
nodeID string
written int
lastResponse string
degradations []MarkdownDegradation
}
// docWritePipeline is the unified entry point for doc create/update with
@@ -61,7 +76,7 @@ type DocWriteResult struct {
// Phases:
//
// 0. Pre-check: warn if --content literal is long
// 1. Strategy: single write (≤initialChunkSize) or chunked
// 1. Strategy: single write (≤DefaultMarkdownChunkRunes) or chunked
// 2. Write: single call or adaptive chunked writes
// 3. Output: JSON result
func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]any,
@@ -84,25 +99,37 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
defer stop()
// Phase 1+2: strategy selection and write
var nodeID string
var chunksWritten int
var lastResponse string
var outcome chunkedWriteOutcome
var writeErr error
if markdown == "" || runeCount <= initialChunkSize {
if markdown == "" || runeCount <= DefaultMarkdownChunkRunes {
// Single write path
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
chunksWritten = 1
outcome.nodeID, outcome.lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
outcome.written = 1
if writeErr != nil && isTimeoutError(writeErr.Error()) {
// The server may have committed the write before the client observed the
// timeout. Replaying create/append here can duplicate a document or
// content, so fail closed and require inspection before any retry.
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
writeErr = docWriteUnknownStateError(operation, outcome.nodeID, "single_write", 0, 1, writeErr, nil)
}
} else {
// Chunked write path
// Chunked write path. --index cannot survive chunking: each chunk creates
// an unpredictable number of blocks, so the insertion point for chunk 2
// is unknowable. Fail closed rather than silently ignore the flag.
if _, hasIndex := toolArgs["index"]; hasIndex {
return apperrors.NewValidation(
fmt.Sprintf("内容长度 %d 字符超过单次写入上限 %d,需要自动分片,而 --index 在分片写入下无法保证插入位置", runeCount, DefaultMarkdownChunkRunes),
apperrors.WithOperation(operation),
apperrors.WithReason("doc_write_index_with_chunking"),
apperrors.WithRetryable(false),
apperrors.WithActions(
"去掉 --index 追加到文档末尾",
"或把内容拆成小于上限的多段,各自带 --index 分别写入",
),
)
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 内容较长 (%d 字符),自动分片写入...", runeCount))
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize)
outcome, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, DefaultMarkdownChunkRunes)
}
if writeErr != nil {
@@ -112,11 +139,12 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
// Phase 3: output
result := DocWriteResult{
Success: true,
NodeID: nodeID,
ChunksWritten: chunksWritten,
NodeID: outcome.nodeID,
ChunksWritten: outcome.written,
Degradations: outcome.degradations,
}
if json.Valid([]byte(lastResponse)) {
result.ServerResponse = json.RawMessage(lastResponse)
if json.Valid([]byte(outcome.lastResponse)) {
result.ServerResponse = json.RawMessage(outcome.lastResponse)
}
return deps.Out.PrintJSON(result)
}
@@ -131,18 +159,19 @@ func singleWrite(ctx context.Context, toolName string, toolArgs map[string]any)
return nodeID, resultText, nil
}
// chunkedWrite performs adaptive chunked writing.
// For doc create: first chunk creates the document directly (with content), rest append.
// For doc update with overwrite: first chunk uses overwrite, rest use append.
// Returns nodeID, chunks written, last server response text, and error.
// chunkedWrite writes markdown as a sequence of independently valid chunks.
// For doc create: the first chunk creates the document directly (with content),
// the rest append. For doc update with overwrite: the first chunk uses overwrite,
// the rest use append.
func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
markdown string, operation string, startChunkSize int) (string, int, string, error) {
markdown string, operation string, chunkSize int) (chunkedWriteOutcome, error) {
var nodeID string
var lastResponse string
chunkSize := startChunkSize
chunks := splitMarkdownSafe(markdown, chunkSize)
writtenCount := 0
plan := SplitMarkdownForAppend(markdown, chunkSize)
chunks := plan.Chunks
out := chunkedWriteOutcome{degradations: plan.Degradations}
for _, warning := range plan.Warnings() {
deps.Out.PrintInfo("[WARN] " + warning)
}
// --- Write first chunk ---
if toolName == "create_document" {
@@ -154,81 +183,89 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (1/%d),%d 字符 (create)...",
len(chunks), utf8.RuneCountInString(chunks[0])))
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
out.lastResponse = resultText
if err != nil {
if isTimeoutError(err.Error()) {
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
return out, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err, plan.Degradations)
}
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
return out, fmt.Errorf("创建文档失败: %w", err)
}
nodeID = extractNodeIDFromResult(resultText)
if nodeID == "" {
return "", 0, resultText, fmt.Errorf("创建文档成功但无法提取 nodeId")
out.nodeID = extractNodeIDFromResult(resultText)
if out.nodeID == "" {
return out, fmt.Errorf("创建文档成功但无法提取 nodeId")
}
lastResponse = resultText
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 文档已创建 (nodeId=%s)", nodeID))
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 文档已创建 (nodeId=%s)", out.nodeID))
} else {
if id, ok := toolArgs["nodeId"].(string); ok {
nodeID = id
out.nodeID = id
}
firstMode := "append"
if m, ok := toolArgs["mode"].(string); ok {
firstMode = m
}
updateArgs := map[string]any{
"nodeId": nodeID,
"nodeId": out.nodeID,
"markdown": chunks[0],
"mode": firstMode,
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (1/%d),%d 字符 (%s)...",
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
out.lastResponse = resultText
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
return out, docWriteUnknownStateError(operation, out.nodeID, "chunk_1", 0, len(chunks), err, plan.Degradations)
}
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
return out, fmt.Errorf("第 1 片写入失败: %w", err)
}
lastResponse = resultText
}
writtenCount = 1
out.written = 1
// --- Write remaining chunks with append ---
for i := 1; i < len(chunks); i++ {
if ctx.Err() != nil {
return nodeID, writtenCount, lastResponse, fmt.Errorf("写入被中断,已完成 %d/%d 片", writtenCount, len(chunks))
return out, fmt.Errorf("写入被中断,已完成 %d/%d 片", out.written, len(chunks))
}
chunk := chunks[i]
preview := chunk
if len(preview) > 80 {
preview = preview[:80]
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (%d/%d),%d 字符, preview=[%s]...",
i+1, len(chunks), utf8.RuneCountInString(chunk), preview))
i+1, len(chunks), utf8.RuneCountInString(chunk), previewRunes(chunk, 80)))
updateArgs := map[string]any{
"nodeId": nodeID,
"nodeId": out.nodeID,
"markdown": chunk,
"mode": "append",
}
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
out.lastResponse = resultText
if err != nil {
if isTimeoutError(err.Error()) {
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
return out, docWriteUnknownStateError(
operation, out.nodeID, fmt.Sprintf("chunk_%d", i+1), out.written, len(chunks), err, plan.Degradations,
)
}
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
return out, fmt.Errorf("分片 %d 写入失败: %w", out.written+1, err)
}
lastResponse = resultText
writtenCount++
out.written++
}
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 全部 %d 个分片写入完成", writtenCount))
return nodeID, writtenCount, lastResponse, nil
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 全部 %d 个分片写入完成", out.written))
return out, nil
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
// previewRunes truncates to at most n runes. Slicing by byte would cut a
// multi-byte character in half and put invalid UTF-8 into the log line.
func previewRunes(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n])
}
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int,
cause error, degradations []MarkdownDegradation) error {
details := map[string]any{
"status": "unknown",
"nodeId": nodeID,
@@ -236,6 +273,12 @@ func docWriteUnknownStateError(operation, nodeID, stage string, written, total i
"chunksTotal": total,
"failedStage": stage,
}
if len(degradations) > 0 {
// Resuming safely needs to know which boundaries carried injected repair
// text, because a chunk that begins with a repeated table header is not
// the same as the raw source at that offset.
details["degradations"] = degradations
}
return apperrors.NewAPI(
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
apperrors.WithOperation(operation),
@@ -1,7 +1,9 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"strings"
@@ -49,13 +51,104 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
}
chunked := strings.Repeat("x", initialChunkSize+100)
chunked := strings.Repeat("x", DefaultMarkdownChunkRunes+100)
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}}})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "markdown": chunked}, chunked, "update"); err != nil {
t.Fatalf("long content chunking: %v", err)
}
}
func TestCrossPlatformCoverageDocWriteRejectsIndexWhenChunking(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
// --index cannot be propagated across chunks: each chunk creates an unknown
// number of blocks, so the insertion point for chunk 2 is unknowable. Before
// this guard, chunkedWrite rebuilt the tool args with only nodeId/markdown/mode
// and the flag was silently dropped.
long := strings.Repeat("x", DefaultMarkdownChunkRunes+10)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}}}
installScriptedCaller(t, caller)
err := docWritePipeline(docWriteCoverageCommand(), "update_document",
map[string]any{"nodeId": "node", "mode": "append", "index": 3}, long, "update")
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_index_with_chunking" {
t.Fatalf("expected a fail-closed validation error, got %#v", err)
}
if caller.calls != 0 {
t.Fatalf("must reject before writing anything, calls=%d", caller.calls)
}
// The same args below the limit stay allowed, so the guard is scoped to
// chunking rather than banning --index outright.
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document",
map[string]any{"nodeId": "node", "mode": "append", "index": 3}, "short", "update"); err != nil {
t.Fatalf("short content with --index must still write: %v", err)
}
}
func TestCrossPlatformCoverageDocWriteSurfacesDegradations(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
// An oversized table must be split with its header repeated, and the caller
// must be told so — silently turning one table into three would otherwise
// look like a clean write.
rows := strings.Repeat("| 张三 | 技术部 | 10086 |\n", 4000)
content := "| 姓名 | 部门 | 工号 |\n|---|---|---|\n" + rows
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}, {text: `{}`}, {text: `{}`}}}
installScriptedCaller(t, caller)
// installScriptedCaller initializes deps and discards output; capture stdout
// afterwards so the result JSON can be inspected.
var stdout bytes.Buffer
deps.Out = NewFormatterWithWriters(&stdout, &bytes.Buffer{})
if err := docWritePipeline(docWriteCoverageCommand(), "update_document",
map[string]any{"nodeId": "node", "mode": "overwrite"}, content, "update"); err != nil {
t.Fatalf("chunked table write: %v", err)
}
// The result JSON follows the [INFO]/[WARN] progress lines on the same stream.
out := stdout.String()
if !strings.Contains(out, "[WARN] 内容过长已分片") {
t.Errorf("no warning line for the table split: %q", out)
}
var result DocWriteResult
if err := json.Unmarshal([]byte(out[strings.Index(out, "{"):]), &result); err != nil {
t.Fatalf("decode result: %v (stdout=%q)", err, out)
}
if result.ChunksWritten < 2 {
t.Fatalf("expected a chunked write, got %#v", result)
}
if len(result.Degradations) == 0 {
t.Fatalf("table split was not reported: %#v", result)
}
for _, d := range result.Degradations {
if d.Kind != "table_split" || !strings.HasPrefix(d.InjectedPrefix, "| 姓名 ") {
t.Errorf("degradation = %#v", d)
}
}
}
func TestCrossPlatformCoverageDocWritePreviewTruncatesByRune(t *testing.T) {
// Slicing by byte would split a multi-byte character and put invalid UTF-8
// into the progress line.
for _, tc := range []struct {
in string
n int
want string
}{
{"abc", 5, "abc"},
{"abcdef", 3, "abc"},
{strings.Repeat("界", 5), 2, "界界"},
} {
if got := previewRunes(tc.in, tc.n); got != tc.want {
t.Errorf("previewRunes(%q,%d) = %q, want %q", tc.in, tc.n, got, tc.want)
}
}
}
func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *testing.T) {
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
@@ -63,9 +156,9 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
markdown := strings.Repeat("x", 24000)
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
outcome, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
if err == nil || outcome.written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", outcome.written, caller.calls, err)
}
}
@@ -87,13 +180,13 @@ func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
installScriptedCaller(t, caller)
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
outcome, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
var typed *apperrors.Error
if err == nil || !errors.As(err, &typed) {
t.Fatalf("error = %#v", err)
}
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
if outcome.nodeID != tc.wantNode || outcome.written != 0 || caller.calls != 1 {
t.Fatalf("node=%q written=%d calls=%d", outcome.nodeID, outcome.written, caller.calls)
}
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("unknown commit metadata = %#v", typed)
+11 -11
View File
@@ -375,12 +375,12 @@ func newDriveCommand() *cobra.Command {
},
},
})
driveCmd := &cobra.Command{
driveCmd := newGroupCommand(&cobra.Command{
Use: "drive",
Short: "钉盘文件管理",
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
RunE: groupRunE,
}
})
driveListCmd := &cobra.Command{
Use: "list",
@@ -2030,14 +2030,14 @@ func newDriveCommand() *cobra.Command {
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
// ── drive permission (文档节点权限管理) ──
drivePermissionCmd := &cobra.Command{
drivePermissionCmd := newGroupCommand(&cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档节点权限管理",
Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。
注意: 仅适用于文档空间节点,不适用于钉盘文件。`,
RunE: groupRunE,
}
})
drivePermAddCmd := &cobra.Command{
Use: "add",
@@ -2806,12 +2806,12 @@ func newDriveCommand() *cobra.Command {
_ = driveRenameCmd.Flags().MarkHidden("title")
// ── drive recycle 子命令组 ──
recycleCmd := &cobra.Command{
recycleCmd := newGroupCommand(&cobra.Command{
Use: "recycle",
Short: "钉盘回收站管理",
Long: `管理钉盘回收站:查看回收站列表、还原回收项。`,
RunE: groupRunE,
}
})
recycleListCmd := &cobra.Command{
Use: "list",
@@ -2921,7 +2921,7 @@ func newDriveCommand() *cobra.Command {
// ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)──
// folder create → dws wiki node create --type folder
driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE}
driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE})
driveFolderCreateCmd := &cobra.Command{
Use: "create",
Short: "创建文件夹(deprecated)",
@@ -2951,12 +2951,12 @@ func newDriveCommand() *cobra.Command {
driveFolderCmd.AddCommand(driveFolderCreateCmd)
// ── drive publish (文件互联网公开发布管理) ──
drivePublishCmd := &cobra.Command{
drivePublishCmd := newGroupCommand(&cobra.Command{
Use: "publish",
Short: "文件互联网公开发布管理",
Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`,
RunE: groupRunE,
}
})
drivePublishSetCmd := &cobra.Command{
Use: "set",
@@ -3251,11 +3251,11 @@ func newDriveCommand() *cobra.Command {
_ = driveRecentCmd.Flags().MarkHidden("page-token")
// ── drive star (文档收藏管理) ──
driveStarCmd := &cobra.Command{
driveStarCmd := newGroupCommand(&cobra.Command{
Use: "star",
Short: "文档收藏管理",
RunE: groupRunE,
}
})
driveStarAddCmd := &cobra.Command{
Use: "add",
Short: "收藏文档",
File diff suppressed because it is too large Load Diff
-503
View File
@@ -1,503 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func withEduAppCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func runEduApp(t *testing.T, args ...string) error {
t.Helper()
cmd := newEduAppCommand()
cmd.SetArgs(args)
return cmd.Execute()
}
// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag
// populated, so all optional-field branches and the dispatch line are covered.
func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) {
cases := [][]string{
{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"},
{"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"},
{"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"},
{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"},
{"report", "get", "--ids", "1001,1002"},
{"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"},
{"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"},
{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"},
{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"},
{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"},
{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true",
"--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29",
"--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s",
"--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "get", "--notice-id", "12345", "--user-name", "张三"},
{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"},
{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"},
{"card", "end", "--card-id", "1"},
{"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"},
{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"},
{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三",
"--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29",
"--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl",
"--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`},
{"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"},
{"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"},
{"diploma", "get", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"},
{"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"},
{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"},
{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习",
"--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v",
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
"--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s",
"--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW",
"--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"},
{"homework", "delete", "--homework-id", "1", "--user-name", "张三"},
{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c",
"--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"},
{"homework", "get", "--homework-id", "1", "--user-name", "张三"},
{"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"},
{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
{"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2",
"--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 /
// page-size<=0 / the >0 else arms) that the positive-value happy paths skip.
func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) {
cases := [][]string{
{"report", "by-teacher", "--page", "0", "--limit", "0"},
{"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"},
{"notice", "list-by-teacher", "--page", "0", "--page-size", "0"},
{"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"},
{"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"},
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"},
{"diploma", "list-by-teacher", "--page", "0", "--limit", "0"},
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"},
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"},
{"homework", "list-by-teacher", "--page", "0", "--page-size", "0"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduAppCaller(t)
if err := runEduApp(t, args...); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"},
{"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"},
{"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"},
{"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"},
{"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"},
{"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"},
{"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"},
{"student-list missing students", []string{"task", "student-list"}, "students"},
{"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"},
{"report get missing ids", []string{"report", "get"}, "ids"},
{"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"},
{"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"},
{"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"},
{"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"},
{"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"},
{"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"},
{"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"},
{"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"},
{"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"},
{"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"},
{"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"},
{"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"},
{"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"},
{"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"},
{"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"},
{"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"},
{"notice get missing notice-id", []string{"notice", "get"}, "notice-id"},
{"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"},
{"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"},
{"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"},
{"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"},
{"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"},
{"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"},
{"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"},
{"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"},
{"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"},
{"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"},
{"card end missing card-id", []string{"card", "end"}, "card-id"},
{"card list missing status", []string{"card", "list"}, "status"},
{"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"},
{"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"},
{"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"},
{"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"},
{"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"},
{"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"},
{"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"},
{"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"},
{"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"},
{"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"},
{"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"},
{"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"},
{"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"},
{"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"},
{"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"},
{"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"},
{"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"},
{"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"},
{"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"},
{"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"},
{"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"},
{"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"},
{"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"},
{"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"},
{"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"},
{"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"},
{"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"},
{"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"},
{"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"},
{"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"},
{"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"},
{"homework get missing homework-id", []string{"homework", "get"}, "homework-id"},
{"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"},
{"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"},
{"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"},
{"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"},
{"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"},
{"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"},
{"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"},
{"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"},
{"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"},
{"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"},
{"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"},
{"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"},
{"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"},
{"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduAppCaller(t)
err := runEduApp(t, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) {
if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduAppParseCSV = %#v", got)
}
ids, err := eduAppParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduAppParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduAppParseIntCSV invalid = nil error")
}
}
func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) {
t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_ai_message_summary_list" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != int64(100) {
t.Fatalf("classId = %v, want 100", input["classId"])
}
if input["cid"] != "cidxxx" {
t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
if input["status"] != int64(1) {
t.Fatalf("status = %v, want 1", input["status"])
}
})
t.Run("report get dispatches get_report", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "get_report" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_report")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
ids, ok := input["schoolReportIdList"].([]int64)
if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 {
t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"])
}
})
t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "query_student_circle_posts" {
t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["classId"] != "12345" {
t.Fatalf("classId = %v, want %q", input["classId"], "12345")
}
if input["studentId"] != "stu1" {
t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1")
}
if input["targetRole"] != "guardian" {
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
}
})
t.Run("card end dispatches end_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "end", "--card-id", "999"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "end_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "end_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(999) {
t.Fatalf("cardId = %v, want 999", input["cardId"])
}
})
t.Run("card update dispatches update_card", func(t *testing.T) {
caller := withEduAppDispatchCaller(t)
cmd := newEduAppCommand()
cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-app" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
}
if caller.tool != "update_card" {
t.Fatalf("tool = %q, want %q", caller.tool, "update_card")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
}
if input["cardId"] != int64(77) {
t.Fatalf("cardId = %v, want 77", input["cardId"])
}
if input["identifier"] != "org1-staff1-uuid" {
t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid")
}
if input["title"] != "新标题" {
t.Fatalf("title = %v, want %q", input["title"], "新标题")
}
if input["shouldSendUpdateMsg"] != true {
t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"])
}
})
}
// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduAppConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduAppCommand())
return root
}
// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"notice delete",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345"},
"delete_notice",
map[string]any{"noticeId": int64(12345)},
},
{
"notice delete with user-name",
[]string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
"delete_notice",
map[string]any{"noticeId": int64(12345), "userName": "张三"},
},
{
"homework delete",
[]string{"edu-app", "homework", "delete", "--homework-id", "12345"},
"delete_homework",
map[string]any{"homeworkId": int64(12345)},
},
{
"diploma delete",
[]string{"edu-app", "diploma", "delete", "--diploma-id", "12345"},
"delete_diploma",
map[string]any{"diplomaId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduAppConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-app" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
File diff suppressed because it is too large Load Diff
-672
View File
@@ -1,672 +0,0 @@
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newTestEduContactRoot() *cobra.Command {
return newEduContactCommand()
}
// ──────────────────────────────────────────────────────────
// 命令注册测试 — 验证所有子命令路径是否正确注册
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) {
root := newTestEduContactRoot()
paths := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "class-list"},
// class — 原有
{"class", "detail"},
{"class", "students"},
{"class", "teachers"},
{"class", "same-name"},
{"class", "user-role"},
{"class", "search-by-name"},
{"class", "headmaster"},
// class — 新增
{"class", "search-by-teacher"},
{"class", "add-student"},
{"class", "add-teachers"},
{"class", "add-unofficial-student"},
{"class", "delete-students"},
{"class", "delete-teacher"},
{"class", "modify-student-info"},
{"class", "move-student"},
{"class", "update-info"},
{"class", "update-student"},
{"class", "update-student-mobile"},
{"class", "update-student-number"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course"},
}
for _, path := range paths {
if _, _, err := root.Find(path); err != nil {
t.Errorf("command path %v not found: %v", path, err)
}
}
}
// ──────────────────────────────────────────────────────────
// 参数校验测试 — 验证必填参数缺失时返回错误
// ──────────────────────────────────────────────────────────
func executeCommand(root *cobra.Command, args ...string) error {
root.SetArgs(args)
return root.Execute()
}
func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "search-by-teacher")
if err == nil {
t.Fatal("expected error for missing --name, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3")
if err == nil {
t.Fatal("expected error for invalid --is-adviser, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --origin-class-id, got nil")
}
}
func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --mobile, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-students", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-user-ids, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1")
if err == nil {
t.Fatal("expected error for missing --dept-id, got nil")
}
}
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345")
if err == nil {
t.Fatal("expected error for missing --student-staff-ids, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch")
if err == nil {
t.Fatal("expected error for missing --student-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch")
if err == nil {
t.Fatal("expected error for invalid --patriarchs JSON, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing --student-number, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--nick", "火箭班")
if err == nil {
t.Fatal("expected error for missing --class-id, got nil")
}
}
func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群")
if err == nil {
t.Fatal("expected error for --group-name without --conversation-id, got nil")
}
}
func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345")
if err == nil {
t.Fatal("expected error for missing --teacher-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1")
if err == nil {
t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil")
}
}
func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1")
if err == nil {
t.Fatal("expected error for --patriarch-user-id without --relation, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000")
if err == nil {
t.Fatal("expected error for missing --student-name, got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三")
if err == nil {
t.Fatal("expected error for missing mobile (student or parent), got nil")
}
}
func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json")
if err == nil {
t.Fatal("expected error for invalid --mother JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course")
if err == nil {
t.Fatal("expected error for missing --teacher-class-infos, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json")
if err == nil {
t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil")
}
}
func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) {
root := newTestEduContactRoot()
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]")
if err == nil {
t.Fatal("expected error for empty --teacher-class-infos array, got nil")
}
}
// ──────────────────────────────────────────────────────────
// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller)
// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。
// ──────────────────────────────────────────────────────────
// withEduContactCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduContactCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) {
withEduContactCaller(t)
cases := [][]string{
// school
{"school", "roles"},
{"school", "structure"},
{"school", "periods"},
{"school", "type"},
{"school", "stats"},
{"school", "stats", "--statistics-type", "1"},
{"school", "class-list"},
// class — 读操作
{"class", "detail", "--dept-id", "123"},
{"class", "students", "--dept-id", "123"},
{"class", "teachers", "--dept-id", "123"},
{"class", "same-name", "--dept-id", "123"},
{"class", "user-role", "--dept-id", "123"},
{"class", "search-by-name", "--query-type", "student", "--name", "张三"},
{"class", "headmaster", "--class-name", "一年级1班"},
{"class", "search-by-teacher", "--name", "张老师"},
// class — update-student 各分支
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1",
"--student-name", "张三", "--student-number", "S1", "--append-patriarch",
"--patriarchs", `[{"userId":"uid1","relation":"F"}]`},
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"},
// class — add-student 各手机号来源分支
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--student-mobile", "13800138000", "--student-user-id", "u1",
"--student-number", "S1", "--virtual-account-id", "v1"},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--mother", `{"mobile":"13800138000","relation":"M"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--father", `{"mobile":"13900139000","relation":"F"}`},
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
"--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`},
// class — modify-student-info 两个分支
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"},
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1",
"--patriarch-user-id", "p1", "--relation", "父亲"},
// class — delete-teacher
{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"},
// class — update-info 三个可选分支
{"class", "update-info", "--class-id", "123", "--nick", "火箭班"},
{"class", "update-info", "--class-id", "123", "--expected-student-num", "45"},
{"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"},
// class — update-student-number
{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"},
// class — add-unofficial-student
{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"},
// class — delete-students
{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"},
// class — update-student-mobile
{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"},
// class — move-student
{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"},
// class — add-teachers 默认/班主任
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"},
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"},
// family
{"family", "children"},
{"family", "parents"},
// teacher
{"teacher", "classes"},
{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`},
}
for _, args := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, args...); err != nil {
t.Errorf("happy path %v returned error: %v", args, err)
}
}
}
// TestEduContactErrorPathsRemaining covers validation branches not exercised by
// the existing error tests, ensuring 100% changed-code coverage.
func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) {
cases := []struct {
name string
args []string
}{
// eduRequiredIntFlag:空值 + 非整数
{"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}},
{"detail-missing-dept", []string{"class", "detail"}},
{"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}},
{"students-missing-dept", []string{"class", "students"}},
{"teachers-missing-dept", []string{"class", "teachers"}},
{"samename-missing-dept", []string{"class", "same-name"}},
{"userrole-missing-dept", []string{"class", "user-role"}},
{"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}},
{"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}},
{"headmaster-missing-classname", []string{"class", "headmaster"}},
// update-student
{"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}},
{"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}},
{"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}},
// add-student
{"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}},
{"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}},
{"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}},
{"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}},
{"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}},
// modify-student-info
{"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}},
{"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}},
// delete-teacher
{"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}},
// update-info
{"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}},
{"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}},
// update-student-number
{"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}},
{"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}},
// add-unofficial-student
{"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}},
{"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}},
// delete-students
{"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}},
{"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}},
// update-student-mobile
{"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}},
{"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}},
// move-student
{"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}},
{"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}},
// add-teachers
{"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}},
{"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}},
}
for _, tc := range cases {
root := newTestEduContactRoot()
if err := executeCommand(root, tc.args...); err == nil {
t.Errorf("%s: expected error, got nil", tc.name)
}
}
}
// ──────────────────────────────────────────────────────────
// Dispatch 验证测试 — 验证命令正确派发到 MCP Server
// ──────────────────────────────────────────────────────────
// withEduContactDispatchCaller installs a non-dry-run capture caller so that
// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify
// the dispatched productID, tool name, and args.
func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) {
t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "detail", "--dept-id", "123"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "get_class_detail" {
t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(123) {
t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"])
}
})
t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "query_class_by_guardian_name" {
t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["queryType"] != "student" {
t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student")
}
if input["name"] != "张三" {
t.Errorf("input[name] = %v, want %q", input["name"], "张三")
}
})
t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"school", "stats", "--statistics-type", "2"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "statistics_school" {
t.Errorf("tool = %q, want %q", caller.tool, "statistics_school")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["statisticsType"] != int64(2) {
t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"])
}
})
t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "batch_add_class_teacher" {
t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
if input["deptId"] != int64(789) {
t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"])
}
teacherUserIds, ok := input["teacherUserIds"].([]string)
if !ok {
t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"])
}
if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" {
t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds)
}
if input["isAdviser"] != int64(1) {
t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"])
}
})
t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) {
caller := withEduContactDispatchCaller(t)
root := newEduContactCommand()
root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
}
if caller.tool != "move_student" {
t.Errorf("tool = %q, want %q", caller.tool, "move_student")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
}
studentUserIds, ok := input["studentUserIds"].([]string)
if !ok {
t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"])
}
if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" {
t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds)
}
if input["originClassId"] != int64(100) {
t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"])
}
if input["targetClassId"] != int64(200) {
t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"])
}
})
}
// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduContactConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduContactCommand())
return root
}
// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"class delete-teacher",
[]string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"},
"delete_teacher",
map[string]any{"classId": int64(12345), "teacherUserId": "userId1"},
},
{
"class delete-students",
[]string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"},
"delete_students",
map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduContactConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-contact" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
-605
View File
@@ -1,605 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-familygroup — 家庭群管理
// 共 6 个工具,按 group(读操作)/ manage(写操作)分组
// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移
// ──────────────────────────────────────────────────────────
func newEduFamilyGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-familygroup",
Selection: contract.ProductSelectionDecl{
AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制",
UseWhen: []string{
"用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。",
},
AvoidWhen: []string{
"家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。",
},
},
})
root := &cobra.Command{
Use: "edu-familygroup",
Short: "家庭群",
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
Hidden: true,
RunE: groupRunE,
}
// ════════════════════════════════════════════════════════════
// group 子命令组 — 家庭群读操作
// ════════════════════════════════════════════════════════════
groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE}
groupCheckExistsCmd := &cobra.Command{
Use: "check-exists",
Short: "检查家庭群是否存在",
Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。
仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家"
dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{
"input": map[string]any{"uid": uid, "groupName": groupName},
})
},
}
DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "check_family_group_exists",
CanonicalPath: "edu-familygroup.check_family_group_exists",
CLIPath: "edu-familygroup group check-exists",
PrimaryCLIPath: "edu-familygroup group check-exists",
},
Description: "检查家庭群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查家庭群是否存在",
UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"},
AvoidWhen: []string{"查询家庭成员信息用 list-children"},
Examples: []string{
"dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "group-name", Property: "input.groupName", Required: boolPtr(true)},
},
},
})
groupListChildrenCmd := &cobra.Command{
Use: "list-children",
Short: "查询家长绑定的孩子列表",
Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织),
包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。
面向家长(GUARDIAN)角色。`,
Example: ` dws edu-familygroup group list-children --uid 12345
dws edu-familygroup group list-children --uid 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{
"input": map[string]any{"uid": uid},
})
},
}
DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "listBoundChildren",
CanonicalPath: "edu-familygroup.listBoundChildren",
CLIPath: "edu-familygroup group list-children",
PrimaryCLIPath: "edu-familygroup group list-children",
},
Description: "查询家长绑定的孩子列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询家长绑定的孩子列表",
UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"},
AvoidWhen: []string{"查看家庭群是否存在用 check-exists"},
Examples: []string{
"dws edu-familygroup group list-children --uid 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// manage 子命令组 — 家庭群写操作
// ════════════════════════════════════════════════════════════
manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE}
manageCreateCmd := &cobra.Command{
Use: "create",
Short: "创建家庭群",
Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。
children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、
birthday / gender / nick / avatar / period / grade / mobile(均可选)。
addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。
新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`,
Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]'
dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`,
RunE: func(cmd *cobra.Command, args []string) error {
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childrenRaw, _ := cmd.Flags().GetString("children")
if strings.TrimSpace(childrenRaw) == "" {
return fmt.Errorf("--children 为必填参数")
}
var children []any
if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil {
return fmt.Errorf("--children 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateChildren(children); err != nil {
return err
}
input := map[string]any{"uid": uid, "children": children}
if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" {
var addGroup map[string]any
if err := json.Unmarshal([]byte(v), &addGroup); err != nil {
return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err)
}
if addGroup == nil {
return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null")
}
input["addGroup"] = addGroup
}
if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") {
input["source"] = v
}
return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "create_family_group",
CanonicalPath: "edu-familygroup.create_family_group",
CLIPath: "edu-familygroup manage create",
PrimaryCLIPath: "edu-familygroup manage create",
},
Description: "创建家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建家庭群并同步创建家庭组织",
UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"},
AvoidWhen: []string{"已有家庭群要加孩子用 add-child"},
Examples: []string{
"dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "children", Property: "input.children", Required: boolPtr(true)},
{Name: "add-group", Property: "input.addGroup"},
{Name: "source", Property: "input.source"},
},
},
})
manageInviteParentCmd := &cobra.Command{
Use: "invite-parent",
Short: "短信邀请家长加入家庭群",
Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。
返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000
dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile")
if err != nil {
return err
}
return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{
"input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile},
})
},
}
DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "invite_parent_to_familygroup",
CanonicalPath: "edu-familygroup.invite_parent_to_familygroup",
CLIPath: "edu-familygroup manage invite-parent",
PrimaryCLIPath: "edu-familygroup manage invite-parent",
},
Description: "短信邀请家长加入家庭群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"},
},
Selection: contract.SelectionSpec{
AgentSummary: "通过短信邀请家长加入家庭群",
UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"},
AvoidWhen: []string{"添加孩子到家庭群用 add-child"},
Examples: []string{
"dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile", Required: boolPtr(true)},
},
},
})
manageAddChildCmd := &cobra.Command{
Use: "add-child",
Short: "为家庭群添加孩子",
Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由):
- 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群
- 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系
- 同时传 --mobile + --students:mobile 优先,走手机号邀请链路
mobile 与 students 至少传一个。
students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。
仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000
dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
name, err := eduFamilyGroupRequiredStringFlag(cmd, "name")
if err != nil {
return err
}
mobile, _ := cmd.Flags().GetString("mobile")
mobile = strings.TrimSpace(mobile)
studentsRaw, _ := cmd.Flags().GetString("students")
studentsRaw = strings.TrimSpace(studentsRaw)
if mobile == "" && studentsRaw == "" {
return fmt.Errorf("--mobile 与 --students 至少传一个")
}
input := map[string]any{"orgId": orgID, "uid": uid, "name": name}
if mobile != "" {
input["mobile"] = mobile
}
if studentsRaw != "" {
var students []any
if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil {
return fmt.Errorf("--students 须为合法 JSON 数组: %w", err)
}
if err := eduFamilyGroupValidateStudents(students); err != nil {
return err
}
input["students"] = students
}
return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{
"input": input,
})
},
}
DeclareLeafMetadata(manageAddChildCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "add_child_to_family_group",
CanonicalPath: "edu-familygroup.add_child_to_family_group",
CLIPath: "edu-familygroup manage add-child",
PrimaryCLIPath: "edu-familygroup manage add-child",
},
Description: "为家庭群添加孩子",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为已有家庭群添加孩子",
UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"},
AvoidWhen: []string{"创建全新家庭群用 create"},
Examples: []string{
"dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "name", Property: "input.name", Required: boolPtr(true)},
{Name: "mobile", Property: "input.mobile"},
{Name: "students", Property: "input.students"},
},
},
})
manageToggleAppCmd := &cobra.Command{
Use: "toggle-app",
Short: "开启或关闭学生应用权限",
Long: `为指定学生号开启或关闭应用权限。
支持的应用类型(--app-type):
- XIAOTIANDI:小天地(学生圈)
- LEARNING_VIDEO:学习视频
直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`,
Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true
dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`,
RunE: func(cmd *cobra.Command, args []string) error {
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
if err != nil {
return err
}
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
if err != nil {
return err
}
childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id")
if err != nil {
return err
}
appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type")
if err != nil {
return err
}
if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" {
return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO")
}
openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open")
if err != nil {
return err
}
var open bool
switch strings.ToLower(openStr) {
case "true":
open = true
case "false":
open = false
default:
return fmt.Errorf("--open 须为 true 或 false")
}
return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{
"input": map[string]any{
"orgId": orgID, "uid": uid, "childStaffId": childStaffID,
"appType": appType, "open": open,
},
})
},
}
DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-familygroup",
Name: "toggle_student_app",
CanonicalPath: "edu-familygroup.toggle_student_app",
CLIPath: "edu-familygroup manage toggle-app",
PrimaryCLIPath: "edu-familygroup manage toggle-app",
},
Description: "开启或关闭学生应用权限",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"},
},
Selection: contract.SelectionSpec{
AgentSummary: "开启或关闭学生应用权限",
UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"},
AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"},
Examples: []string{
"dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
{Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)},
{Name: "app-type", Property: "input.appType", Required: boolPtr(true)},
{Name: "open", Property: "input.open", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// group(读操作)flags
groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)")
groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)")
groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)")
// manage(写操作)flags
manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)")
manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)")
manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)")
manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)")
manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)")
manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)")
manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)")
manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)")
manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)")
manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)")
manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)")
manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)")
manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)")
groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd)
manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd)
root.AddCommand(groupCmd, manageCmd)
return root
}
// eduFamilyGroupValidateChildren validates the --children payload: the array
// must be non-empty, each child must carry a non-empty name and a non-empty
// students array, and each student must carry corpId + staffId.
func eduFamilyGroupValidateChildren(children []any) error {
if len(children) == 0 {
return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子")
}
for i, c := range children {
child, ok := c.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d] 须为 JSON 对象", i)
}
name, _ := child["name"].(string)
if strings.TrimSpace(name) == "" {
return fmt.Errorf("--children[%d].name 为必填字段", i)
}
students, ok := child["students"].([]any)
if !ok || len(students) == 0 {
return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i)
}
for j, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j)
}
corpID, _ := student["corpId"].(string)
if strings.TrimSpace(corpID) == "" {
return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j)
}
staffID, _ := student["staffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j)
}
}
}
return nil
}
// eduFamilyGroupValidateStudents validates the --students payload: the array
// must be non-empty and each element must carry a numeric schoolOrgId and a
// non-empty studentStaffId.
func eduFamilyGroupValidateStudents(students []any) error {
if len(students) == 0 {
return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号")
}
for i, s := range students {
student, ok := s.(map[string]any)
if !ok {
return fmt.Errorf("--students[%d] 须为 JSON 对象", i)
}
switch student["schoolOrgId"].(type) {
case float64, int, int64, json.Number:
default:
return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i)
}
staffID, _ := student["studentStaffId"].(string)
if strings.TrimSpace(staffID) == "" {
return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i)
}
}
return nil
}
// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an
// error if the flag is empty or not a valid integer.
func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduFamilyGroupRequiredStringFlag extracts a required string flag.
func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return "", fmt.Errorf("--%s 为必填参数", name)
}
return v, nil
}
-318
View File
@@ -1,318 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
)
func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"group", "check-exists", "--uid", "1", "--group-name", "小明一家"},
{"group", "list-children", "--uid", "1"},
{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", `{"schoolCorpId":"x"}`, "--source", "1"},
{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"},
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "XIAOTIANDI", "--open", "true"},
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
"--app-type", "LEARNING_VIDEO", "--open", "false"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"},
{"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"},
{"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"},
{"list-children missing uid", []string{"group", "list-children"}, "uid"},
{"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"},
{"create missing children", []string{"manage", "create", "--uid", "1"}, "children"},
{"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"},
{"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"},
{"create add-group bad json", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "{"}, "add-group"},
{"create add-group null", []string{"manage", "create", "--uid", "1",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
"--add-group", "null"}, "null"},
{"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"},
{"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"},
{"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"},
{"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"},
{"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"},
{"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"},
{"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"},
{"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"},
{"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"},
{"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"},
{"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"},
{"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"},
{"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"},
{"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"},
{"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"},
{"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEduFamilyGroupCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) {
valid := []any{map[string]any{
"name": "小明",
"students": []any{map[string]any{"corpId": "c", "staffId": "s"}},
}}
if err := eduFamilyGroupValidateChildren(valid); err != nil {
t.Fatalf("valid children error = %v", err)
}
cases := []struct {
name string
children []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"},
{"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"},
{"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"},
{"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"},
{"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) {
valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}}
if err := eduFamilyGroupValidateStudents(valid); err != nil {
t.Fatalf("valid students error = %v", err)
}
cases := []struct {
name string
students []any
want string
}{
{"empty", []any{}, "不能为空数组"},
{"non-object", []any{1}, "须为 JSON 对象"},
{"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"},
{"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) {
t.Run("check-exists", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "check_family_group_exists" {
t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(123) {
t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"])
}
if input["groupName"] != "测试家庭" {
t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭")
}
})
t.Run("list-children", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"group", "list-children", "--uid", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "listBoundChildren" {
t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(456) {
t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"])
}
})
t.Run("create", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "create", "--uid", "789",
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "create_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["uid"] != int64(789) {
t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"])
}
if input["children"] == nil {
t.Fatalf("input[\"children\"] is nil, want non-nil")
}
})
t.Run("invite-parent", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "invite_parent_to_familygroup" {
t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["orgId"] != int64(1) {
t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"])
}
if input["uid"] != int64(2) {
t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"])
}
if input["mobile"] != "13800138000" {
t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000")
}
})
t.Run("toggle-app", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2",
"--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "toggle_student_app" {
t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
}
if input["appType"] != "XIAOTIANDI" {
t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI")
}
if input["open"] != true {
t.Fatalf("input[\"open\"] = %#v, want true", input["open"])
}
})
t.Run("add-child", func(t *testing.T) {
caller := withEduFamilyGroupDispatchCaller(t)
cmd := newEduFamilyGroupCommand()
cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890",
"--name", "小明", "--mobile", "13900139000",
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() = %v", err)
}
if caller.productID != "edu-familygroup" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
}
if caller.tool != "add_child_to_family_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group")
}
want := map[string]any{
"input": map[string]any{
"orgId": int64(12345),
"uid": int64(67890),
"name": "小明",
"mobile": "13900139000",
"students": []any{
map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"},
},
},
}
if !reflect.DeepEqual(caller.args, want) {
t.Fatalf("args = %#v, want %#v", caller.args, want)
}
})
}
-846
View File
@@ -1,846 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
// dws edu-group — 家校群管理
// 共 14 个工具,按 student-group / class-group / batch 分组
// ──────────────────────────────────────────────────────────
func newEduGroupCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "edu-group",
Selection: contract.ProductSelectionDecl{
AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作",
UseWhen: []string{
"用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。",
},
AvoidWhen: []string{
"家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。",
},
},
})
root := &cobra.Command{
Use: "edu-group",
Short: "家校群",
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
Hidden: true,
RunE: groupRunE,
}
// ════════════════════════════════════════════════════════════
// student-group 子命令组 — 师生群管理
// ════════════════════════════════════════════════════════════
studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE}
studentGroupInfoCmd := &cobra.Command{
Use: "info",
Short: "查询班级师生群信息",
Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group info --dept-id 12345
dws edu-group student-group info --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_group_info",
CanonicalPath: "edu-group.get_class_group_info",
CLIPath: "edu-group student-group info",
PrimaryCLIPath: "edu-group student-group info",
},
Description: "查询班级师生群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群信息",
UseWhen: []string{"需要查询指定班级的师生群会话ID时"},
AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"},
Examples: []string{
"dws edu-group student-group info --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查组织是否已创建师生群",
Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`,
Example: ` dws edu-group student-group exists --dept-id 12345
dws edu-group student-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_group_exists",
CanonicalPath: "edu-group.check_class_group_exists",
CLIPath: "edu-group student-group exists",
PrimaryCLIPath: "edu-group student-group exists",
},
Description: "检查组织是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查组织是否已创建师生群",
UseWhen: []string{"需要判断指定班级是否已创建师生群时"},
AvoidWhen: []string{"查询师生群成员用 student-group members"},
Examples: []string{
"dws edu-group student-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupMembersCmd := &cobra.Command{
Use: "members",
Short: "查询师生群成员列表",
Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group members --dept-id 12345
dws edu-group student-group members --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_members",
CanonicalPath: "edu-group.get_group_members",
CLIPath: "edu-group student-group members",
PrimaryCLIPath: "edu-group student-group members",
},
Description: "查询师生群成员列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询师生群成员列表",
UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"},
AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"},
Examples: []string{
"dws edu-group student-group members --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupIsInCmd := &cobra.Command{
Use: "is-in",
Short: "判断用户是否在师生群中",
Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group is-in --dept-id 12345
dws edu-group student-group is-in --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "is_in_class_group",
CanonicalPath: "edu-group.is_in_class_group",
CLIPath: "edu-group student-group is-in",
PrimaryCLIPath: "edu-group student-group is-in",
},
Description: "判断用户是否在师生群中",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "判断用户是否在师生群中",
UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"},
AvoidWhen: []string{"查询群成员列表用 student-group members"},
Examples: []string{
"dws edu-group student-group is-in --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "查询班级群会话详情",
Long: `查询指定班级师生群的会话详情。
返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group student-group conversation --dept-id 12345
dws edu-group student-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_group_conversation_info",
CanonicalPath: "edu-group.get_group_conversation_info",
CLIPath: "edu-group student-group conversation",
PrimaryCLIPath: "edu-group student-group conversation",
},
Description: "查询班级群会话详情",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询班级师生群会话详情",
UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需群会话ID用 student-group info"},
Examples: []string{
"dws edu-group student-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupCreateCmd := &cobra.Command{
Use: "create",
Short: "创建班级师生群",
Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。
前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group create --dept-id 12345
dws edu-group student-group create --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "create_class_group",
CanonicalPath: "edu-group.create_class_group",
CLIPath: "edu-group student-group create",
PrimaryCLIPath: "edu-group student-group create",
},
Description: "创建班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定班级创建师生群",
UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"},
AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"},
Examples: []string{
"dws edu-group student-group create --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
studentGroupDisbandCmd := &cobra.Command{
Use: "disband",
Short: "解散班级师生群",
Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`,
Example: ` dws edu-group student-group disband --dept-id 12345
dws edu-group student-group disband --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "disband_class_group",
CanonicalPath: "edu-group.disband_class_group",
CLIPath: "edu-group student-group disband",
PrimaryCLIPath: "edu-group student-group disband",
},
Description: "解散班级师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "解散班级师生群并删除关联关系",
UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"},
AvoidWhen: []string{"查询师生群信息用 student-group info"},
Examples: []string{
"dws edu-group student-group disband --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// class-group 子命令组 — 班级群(家校群)会话管理
// ════════════════════════════════════════════════════════════
classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE}
classGroupConversationIDCmd := &cobra.Command{
Use: "conversation-id",
Short: "获取班级群会话ID",
Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation-id --dept-id 12345
dws edu-group class-group conversation-id --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation_id",
CanonicalPath: "edu-group.get_class_conversation_id",
CLIPath: "edu-group class-group conversation-id",
PrimaryCLIPath: "edu-group class-group conversation-id",
},
Description: "获取班级群会话ID",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群会话ID",
UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"},
AvoidWhen: []string{"需要完整群信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group conversation-id --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupConversationCmd := &cobra.Command{
Use: "conversation",
Short: "获取班级群完整会话信息",
Long: `获取指定班级的班级群完整会话信息。
返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group conversation --dept-id 12345
dws edu-group class-group conversation --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "get_class_conversation",
CanonicalPath: "edu-group.get_class_conversation",
CLIPath: "edu-group class-group conversation",
PrimaryCLIPath: "edu-group class-group conversation",
},
Description: "获取班级群完整会话信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取班级群完整会话信息",
UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"},
AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"},
Examples: []string{
"dws edu-group class-group conversation --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupExistsCmd := &cobra.Command{
Use: "exists",
Short: "检查班级群是否存在",
Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group exists --dept-id 12345
dws edu-group class-group exists --dept-id 12345 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
if err != nil {
return err
}
return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{
"input": map[string]any{"deptId": deptID},
})
},
}
DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "check_class_conversation_exists",
CanonicalPath: "edu-group.check_class_conversation_exists",
CLIPath: "edu-group class-group exists",
PrimaryCLIPath: "edu-group class-group exists",
},
Description: "检查班级群是否存在",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "检查班级群是否存在",
UseWhen: []string{"需要判断指定班级是否已创建班级群时"},
AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"},
Examples: []string{
"dws edu-group class-group exists --dept-id 12345 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
},
},
})
classGroupListByConversationIDsCmd := &cobra.Command{
Use: "list-by-cids",
Short: "根据会话ID列表批量查询群信息",
Long: `根据群会话ID列表批量查询群的详细信息。
返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
管理员、班主任、老师角色可调用。`,
Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3
dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("conversation-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--conversation-ids 为必填参数")
}
conversationIDs := eduGroupParseCSV(raw)
if len(conversationIDs) == 0 {
return fmt.Errorf("--conversation-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{
"input": map[string]any{"conversationIds": conversationIDs},
})
},
}
DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "list_groups_by_conversation_ids",
CanonicalPath: "edu-group.list_groups_by_conversation_ids",
CLIPath: "edu-group class-group list-by-cids",
PrimaryCLIPath: "edu-group class-group list-by-cids",
},
Description: "根据会话ID列表批量查询群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"},
},
Selection: contract.SelectionSpec{
AgentSummary: "根据会话ID列表批量查询群信息",
UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"},
AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"},
Examples: []string{
"dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)},
},
},
})
// ════════════════════════════════════════════════════════════
// batch 子命令组 — 批量操作
// ════════════════════════════════════════════════════════════
batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE}
batchCheckClassGroupCmd := &cobra.Command{
Use: "check-student-group",
Short: "批量检查班级是否已创建师生群",
Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch check-student-group --class-ids 12345,67890
dws edu-group batch check-student-group --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs, err := eduGroupParseIntCSV(raw)
if err != nil {
return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err)
}
return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_check_class_group",
CanonicalPath: "edu-group.batch_check_class_group",
CLIPath: "edu-group batch check-student-group",
PrimaryCLIPath: "edu-group batch check-student-group",
},
Description: "批量检查班级是否已创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量检查班级是否已创建师生群",
UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"},
AvoidWhen: []string{"单个班级检查用 student-group exists"},
Examples: []string{
"dws edu-group batch check-student-group --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchGetClassConversationsCmd := &cobra.Command{
Use: "get-class-groups",
Short: "批量获取班级群信息",
Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`,
Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890
dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
raw, _ := cmd.Flags().GetString("class-ids")
raw = strings.TrimSpace(raw)
if raw == "" {
return fmt.Errorf("--class-ids 为必填参数")
}
classIDs := eduGroupParseCSV(raw)
if len(classIDs) == 0 {
return fmt.Errorf("--class-ids 不能为空")
}
return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{
"input": map[string]any{"classIds": classIDs},
})
},
}
DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_get_class_conversations",
CanonicalPath: "edu-group.batch_get_class_conversations",
CLIPath: "edu-group batch get-class-groups",
PrimaryCLIPath: "edu-group batch get-class-groups",
},
Description: "批量获取班级群信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"},
},
Selection: contract.SelectionSpec{
AgentSummary: "批量获取班级群会话信息",
UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"},
AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"},
Examples: []string{
"dws edu-group batch get-class-groups --class-ids 12345,67890 --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
},
},
})
batchCreateClassGroupCmd := &cobra.Command{
Use: "create-student-groups",
Short: "批量创建师生群",
Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。
小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`,
Example: ` dws edu-group batch create-student-groups
dws edu-group batch create-student-groups -f json`,
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{
"input": map[string]any{},
})
},
}
DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "edu-group",
Name: "batch_create_class_group",
CanonicalPath: "edu-group.batch_create_class_group",
CLIPath: "edu-group batch create-student-groups",
PrimaryCLIPath: "edu-group batch create-student-groups",
},
Description: "批量创建师生群",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为组织下符合条件的班级批量创建师生群",
UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"},
AvoidWhen: []string{"单个班级创建用 student-group create"},
Examples: []string{
"dws edu-group batch create-student-groups --format json",
},
},
Parameters: []contract.ParamDecl{},
},
})
// ════════════════════════════════════════════════════════════
// flags + 构建命令树
// ════════════════════════════════════════════════════════════
// student-group flags
studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)")
studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)")
// class-group flags
classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)")
// batch flags
batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
studentGroupCmd.AddCommand(
studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd,
studentGroupIsInCmd, studentGroupConversationCmd,
studentGroupCreateCmd, studentGroupDisbandCmd,
)
classGroupCmd.AddCommand(
classGroupConversationIDCmd, classGroupConversationCmd,
classGroupExistsCmd, classGroupListByConversationIDsCmd,
)
batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd)
root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd)
return root
}
// eduGroupRequiredIntFlag extracts a required integer flag, returning an error
// if the flag is empty or not a valid integer.
func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
v, _ := cmd.Flags().GetString(name)
v = strings.TrimSpace(v)
if v == "" {
return 0, fmt.Errorf("--%s 为必填参数", name)
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
}
return n, nil
}
// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values.
func eduGroupParseCSV(raw string) []string {
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
result = append(result, v)
}
}
return result
}
// eduGroupParseIntCSV splits a comma-separated string into int64 values.
func eduGroupParseIntCSV(raw string) ([]int64, error) {
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", v)
}
result = append(result, n)
}
return result, nil
}
-366
View File
@@ -1,366 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// withEduGroupCaller installs a dry-run capture caller so happy-path command
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
// requiring a live MCP transport.
func withEduGroupCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{dryRun: true}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) {
cases := [][]string{
{"student-group", "info", "--dept-id", "123"},
{"student-group", "exists", "--dept-id", "123"},
{"student-group", "members", "--dept-id", "123"},
{"student-group", "is-in", "--dept-id", "123"},
{"student-group", "conversation", "--dept-id", "123"},
{"student-group", "create", "--dept-id", "123"},
{"student-group", "disband", "--dept-id", "123"},
{"class-group", "conversation-id", "--dept-id", "123"},
{"class-group", "conversation", "--dept-id", "123"},
{"class-group", "exists", "--dept-id", "123"},
{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"},
{"batch", "check-student-group", "--class-ids", "1,2"},
{"batch", "get-class-groups", "--class-ids", "1,2"},
{"batch", "create-student-groups"},
}
for _, args := range cases {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) = %v, want nil", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) {
// Each dept-id command must reject an absent --dept-id, covering its own
// error branch as well as the shared eduGroupRequiredIntFlag empty case.
deptIDCommands := [][]string{
{"student-group", "info"},
{"student-group", "exists"},
{"student-group", "members"},
{"student-group", "is-in"},
{"student-group", "conversation"},
{"student-group", "create"},
{"student-group", "disband"},
{"class-group", "conversation-id"},
{"class-group", "conversation"},
{"class-group", "exists"},
}
for _, args := range deptIDCommands {
t.Run(strings.Join(args, " "), func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") {
t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err)
}
})
}
}
func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) {
t.Run("non-integer dept-id", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("non-integer dept-id error = %v", err)
}
})
t.Run("list-by-cids missing conversation-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("missing conversation-ids error = %v", err)
}
})
t.Run("list-by-cids only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
t.Fatalf("empty conversation-ids error = %v", err)
}
})
t.Run("batch check missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch check invalid integer class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
t.Fatalf("invalid integer class-ids error = %v", err)
}
})
t.Run("batch get missing class-ids", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("missing class-ids error = %v", err)
}
})
t.Run("batch get only separators", func(t *testing.T) {
withEduGroupCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
t.Fatalf("empty class-ids error = %v", err)
}
})
}
func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) {
if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("eduGroupParseCSV = %#v", got)
}
ids, err := eduGroupParseIntCSV(" 1 , , 2 ")
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err)
}
if _, err := eduGroupParseIntCSV("1,bad"); err == nil {
t.Fatalf("eduGroupParseIntCSV invalid = nil error")
}
}
// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands
// exercise the full dispatch path through deps.Caller.CallTool.
func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
t.Helper()
caller := &recruitCaptureCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
return caller
}
func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) {
t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "get_class_group_info" {
t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(123) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"])
}
})
t.Run("student-group create dispatches create_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "create_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(456) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"])
}
})
t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.PersistentFlags().Bool("yes", false, "")
cmd.PersistentFlags().Bool("dry-run", false, "")
cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "disband_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
if input["deptId"] != int64(789) {
t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"])
}
})
t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "list_groups_by_conversation_ids" {
t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
cids, ok := input["conversationIds"].([]string)
if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" {
t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"])
}
})
t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) {
caller := withEduGroupDispatchCaller(t)
cmd := newEduGroupCommand()
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute error = %v", err)
}
if caller.productID != "edu-group" {
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
}
if caller.tool != "batch_check_class_group" {
t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group")
}
input, ok := caller.args["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
}
classIDs, ok := input["classIds"].([]int64)
if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 {
t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"])
}
})
}
// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
func newEduGroupConfirmRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
root.AddCommand(newEduGroupCommand())
return root
}
// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个
// user_required 破坏性叶子做成对验证:
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) {
cases := []struct {
name string
args []string
wantTool string
wantInput map[string]any
}{
{
"student-group disband",
[]string{"edu-group", "student-group", "disband", "--dept-id", "12345"},
"disband_class_group",
map[string]any{"deptId": int64(12345)},
},
}
for _, tc := range cases {
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil {
t.Fatalf("expected confirm-gate error without --yes, got nil")
}
if !strings.Contains(err.Error(), "需要用户确认") {
t.Fatalf("expected confirmation gate error, got: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
}
})
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
caller := &recruitCaptureCaller{dryRun: false}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
root := newEduGroupConfirmRoot()
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatalf("Execute() with --yes error = %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
}
if caller.calls[0].productID != "edu-group" {
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group")
}
if caller.calls[0].tool != tc.wantTool {
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
}
gotArgs := caller.calls[0].args
if len(gotArgs) != 1 {
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
}
gotInput, ok := gotArgs["input"].(map[string]any)
if !ok {
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
}
if !reflect.DeepEqual(gotInput, tc.wantInput) {
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
}
})
}
}
+2 -2
View File
@@ -55,12 +55,12 @@ func fileCommentSpaceIDFlag() LeafFlag {
// doc comment, sheet comment, and drive comment. The public command belongs to
// Drive, while the implementation routes to the shared doc-comment MCP server.
func newDriveFileCommentCmd() *cobra.Command {
commentCmd := &cobra.Command{
commentCmd := newGroupCommand(&cobra.Command{
Use: "comment",
Short: "普通文件评论管理",
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
RunE: groupRunE,
}
})
listCmd := NewLeafCommand(LeafSpec{
Use: "list",
+39 -4
View File
@@ -15,15 +15,16 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Re-export cmdutil functions as package-level aliases so that existing product
// files continue to compile with their current (unexported) call sites.
// This avoids a mass-rename in 22 product files while still consolidating the
// implementations in pkg/cmdutil.
// Re-export shared command helpers as package-level aliases so existing product
// files continue to compile with their current (unexported) call sites. This
// avoids a mass-rename while keeping reusable command-resolution and flag
// utilities in cmdutil.
var (
groupRunE = cmdutil.GroupRunE
hintSubCmd = cmdutil.HintSubCmd
@@ -38,6 +39,40 @@ var (
helperAfter = time.After
)
// newGroupCommand declares the ordinary navigation policy used by helper
// command containers. The unified framework compiles this declaration into
// Cobra behavior and command-resolution metadata.
func newGroupCommand(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
// newDeepGroupCommand declares a navigation container whose typo recovery may
// teach exact descendant paths (for example sheet read -> sheet range read).
func newDeepGroupCommand(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupNavigationOnly,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoveryDeep,
})
return command
}
// newHybridGroupCommand declares a business command that also owns children.
// Its existing RunE remains the command's default action.
func newHybridGroupCommand(command *cobra.Command) *cobra.Command {
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
Mode: corecmd.GroupHybrid,
Positionals: corecmd.PositionalsReject,
Recovery: corecmd.RecoverySibling,
})
return command
}
// Deps holds shared dependencies injected from the host application.
type Deps struct {
Caller edition.ToolCaller
+5 -5
View File
@@ -29,7 +29,7 @@ func newHrbrainCommand() *cobra.Command {
},
},
})
root := &cobra.Command{
root := newGroupCommand(&cobra.Command{
Use: "hrbrain",
Short: "组织大脑:人才池、员工档案与人才搜索",
Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。
@@ -47,11 +47,11 @@ func newHrbrainCommand() *cobra.Command {
dws hrbrain search employees-structured 使用高级条件搜索人员
dws hrbrain search fields 获取高级搜索字段列表`,
RunE: groupRunE,
}
})
// ── talent-pool: 人才池管理 ────────────────────────────────
talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}
talentPoolCmd := newGroupCommand(&cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE})
talentPoolListCmd := &cobra.Command{
Use: "list",
@@ -236,7 +236,7 @@ func newHrbrainCommand() *cobra.Command {
// ── profile: 员工档案管理 ──────────────────────────────────
profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}
profileCmd := newGroupCommand(&cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE})
profileMetadataCmd := &cobra.Command{
Use: "metadata",
@@ -493,7 +493,7 @@ func newHrbrainCommand() *cobra.Command {
// ── search: 人才搜索 ─────────────────────────────────────
searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}
searchCmd := newGroupCommand(&cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE})
employeeSearchCmd := &cobra.Command{
Use: "employees",
+374 -17
View File
@@ -25,6 +25,7 @@ import (
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -55,6 +56,8 @@ type importFlowConfig struct {
requireTarget bool
serverID string
includeNodeID bool
resolveDefaultTarget bool
verifyPlacement bool
timeoutAsResult bool
nextCommand string
poll importPollPolicy
@@ -72,6 +75,7 @@ type preparedImportFile struct {
size int64
folder string
workspace string
target string
}
func defaultImportPollPolicy() importPollPolicy {
@@ -101,6 +105,9 @@ func docImportFlowConfig() importFlowConfig {
supportedFormatsText: "docx, doc, xlsx, xls, md, txt, xmind, mark",
folderFlags: []string{"folder", "folder-id"},
workspaceFlags: []string{"workspace", "workspace-id"},
includeNodeID: true,
resolveDefaultTarget: true,
verifyPlacement: true,
nextCommand: "dws doc import get --task-id %s",
poll: defaultImportPollPolicy(),
// 白名单外的格式改走文档空间的文件上传链路
@@ -193,6 +200,13 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
return preparedImportFile{}, fmt.Errorf("--folder-token 与 --workspace 至少需要提供一个(导入目标位置)")
}
target := ""
if folder != "" {
target = "folder_flag"
} else if workspace != "" {
target = "workspace_flag"
}
return preparedImportFile{
path: filePath,
name: name,
@@ -200,9 +214,275 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
size: fileInfo.Size(),
folder: folder,
workspace: workspace,
target: target,
}, nil
}
func resolveDefaultDocImportTarget(ctx context.Context, file *preparedImportFile) error {
if file == nil || file.folder != "" || file.workspace != "" {
return nil
}
text, err := callMCPToolReturnTextOnServer(ctx, "wiki", "list_wikiSpaces", map[string]any{
"wikiSpaceType": "myWikiSpace",
})
if err != nil {
return docImportTargetResolutionError(err)
}
workspaceID, err := parsePersonalDocWorkspaceID(text)
if err != nil {
return docImportTargetResolutionError(err)
}
file.workspace = workspaceID
file.target = "default_personal_workspace"
return nil
}
func parsePersonalDocWorkspaceID(text string) (string, error) {
var root any
if err := json.Unmarshal([]byte(text), &root); err != nil {
return "", fmt.Errorf("解析我的文档空间响应失败: %w", err)
}
spaces, ok := findImportObjectList(root, "wikiSpaces", "spaces")
if !ok || len(spaces) == 0 {
return "", fmt.Errorf("我的文档空间响应没有返回 wikiSpaces")
}
if len(spaces) != 1 {
return "", fmt.Errorf("我的文档空间响应返回 %d 个候选,无法安全选择", len(spaces))
}
workspaceID := importString(spaces[0], "workspaceId")
if workspaceID == "" {
return "", fmt.Errorf("我的文档空间响应缺少 workspaceId")
}
return workspaceID, nil
}
func findImportObjectList(value any, keys ...string) ([]map[string]any, bool) {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
raw, exists := typed[key]
if !exists {
continue
}
items, ok := raw.([]any)
if !ok {
return nil, false
}
objects := make([]map[string]any, 0, len(items))
for _, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, false
}
objects = append(objects, object)
}
return objects, true
}
for _, key := range []string{"result", "data"} {
if nested, exists := typed[key]; exists {
if objects, ok := findImportObjectList(nested, keys...); ok {
return objects, true
}
}
}
}
return nil, false
}
func docImportTargetResolutionError(cause error) error {
return apperrors.NewAPI(
"无法解析默认的“我的文档”目标,导入尚未开始",
apperrors.WithOperation("doc.import"),
apperrors.WithReason("doc_import_default_target_unavailable"),
apperrors.WithFailureStage("resolve_default_target"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithActions("检查是否可访问“我的文档”空间", "也可以显式传入 --folder 或 --workspace 后重试"),
apperrors.WithDetails(map[string]any{"status": "failed", "target": "myWikiSpace"}),
apperrors.WithCause(cause),
)
}
func importString(value map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func parseImportedDocumentInfo(text string) (map[string]any, error) {
var root any
if err := json.Unmarshal([]byte(text), &root); err != nil {
return nil, fmt.Errorf("解析文档元信息响应失败: %w", err)
}
if info, ok := findImportedDocumentInfo(root); ok {
return info, nil
}
return nil, fmt.Errorf("文档元信息响应缺少 nodeId/folderId/workspaceId")
}
func findImportedDocumentInfo(value any) (map[string]any, bool) {
object, ok := value.(map[string]any)
if !ok {
return nil, false
}
if importString(object, "nodeId", "fileId", "dentryUuid", "folderId", "workspaceId") != "" {
return object, true
}
for _, key := range []string{"result", "data", "documentInfo", "document", "doc", "file"} {
if nested, exists := object[key]; exists {
if info, ok := findImportedDocumentInfo(nested); ok {
return info, true
}
}
}
return nil, false
}
func canonicalImportTargetID(raw string) string {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || (parsed.Scheme == "" && parsed.Host == "") {
return raw
}
for _, key := range []string{"workspaceId", "spaceId", "folderId", "nodeId"} {
if value := strings.TrimSpace(parsed.Query().Get(key)); value != "" {
return value
}
}
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for index, segment := range segments {
if index+1 >= len(segments) {
break
}
switch segment {
case "nodes", "spaces", "folders":
if value := strings.TrimSpace(segments[index+1]); value != "" {
return value
}
}
}
return raw
}
func verifyImportedDocumentPlacement(ctx context.Context, file preparedImportFile, taskID, documentURL string) (string, map[string]any, error) {
nodeID := extractNodeIDFromDocURL(documentURL)
if nodeID == "" {
return "", nil, docImportPlacementError(file, taskID, "", nil, fmt.Errorf("导入结果缺少可解析的 documentUrl"))
}
return verifyImportedNodePlacement(ctx, file, taskID, nodeID)
}
func verifyImportedNodePlacement(ctx context.Context, file preparedImportFile, taskID, nodeID string) (string, map[string]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_info", map[string]any{"nodeId": nodeID})
if err != nil {
return nodeID, nil, docImportPlacementError(file, taskID, nodeID, nil, err)
}
info, err := parseImportedDocumentInfo(text)
if err != nil {
return nodeID, nil, docImportPlacementError(file, taskID, nodeID, nil, err)
}
observedNodeID := importString(info, "nodeId", "fileId", "dentryUuid", "id")
if observedNodeID != "" && observedNodeID != nodeID {
return nodeID, info, docImportPlacementError(file, taskID, nodeID, info,
fmt.Errorf("回读 nodeId=%s,与导入结果 nodeId=%s 不一致", observedNodeID, nodeID))
}
if file.folder != "" {
expected := canonicalImportTargetID(file.folder)
observed := importString(info, "folderId")
if observed == "" || observed != expected {
return nodeID, info, docImportPlacementError(file, taskID, nodeID, info,
fmt.Errorf("目标文件夹验证失败:expected=%s observed=%s", expected, observed))
}
}
if file.workspace != "" {
expected := canonicalImportTargetID(file.workspace)
observed := importString(info, "workspaceId")
if observed == "" || observed != expected {
return nodeID, info, docImportPlacementError(file, taskID, nodeID, info,
fmt.Errorf("目标知识库验证失败:expected=%s observed=%s", expected, observed))
}
}
return nodeID, compactImportedDocumentInfo(info, nodeID), nil
}
func compactImportedDocumentInfo(info map[string]any, nodeID string) map[string]any {
result := map[string]any{"nodeId": nodeID}
for _, key := range []string{"folderId", "workspaceId", "name", "contentType"} {
if value := importString(info, key); value != "" {
result[key] = value
}
}
return result
}
func importTargetSummary(file preparedImportFile) map[string]any {
result := map[string]any{"source": file.target}
if file.folder != "" {
result["folderId"] = canonicalImportTargetID(file.folder)
}
if file.workspace != "" {
result["workspaceId"] = canonicalImportTargetID(file.workspace)
}
return result
}
func docImportPlacementError(file preparedImportFile, taskID, nodeID string, observed map[string]any, cause error) error {
details := map[string]any{
"status": "partial_success",
"nodeId": nodeID,
"target": importTargetSummary(file),
"verified": false,
}
if taskID != "" {
details["taskId"] = taskID
}
if observed != nil {
details["observed"] = compactImportedDocumentInfo(observed, nodeID)
}
return apperrors.NewAPI(
"导入或上传已经完成,但目标落点回读验证失败;为避免重复创建,请先按 nodeId 检查文档",
apperrors.WithOperation("doc.import"),
apperrors.WithReason("doc_import_placement_unverified"),
apperrors.WithFailureStage("verify_placement"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("按 nodeId 检查文档当前位置", "确认导入结果前不要重复执行"),
apperrors.WithDetails(details),
apperrors.WithCause(cause),
)
}
func docImportVerificationTargetRequiredError(taskID, documentURL string) error {
details := map[string]any{
"taskId": taskID,
"taskStatus": "completed",
"verified": false,
}
if documentURL != "" {
details["documentUrl"] = documentURL
}
if nodeID := extractNodeIDFromDocURL(documentURL); nodeID != "" {
details["nodeId"] = nodeID
}
return apperrors.NewAPI(
"导入任务已经完成,但未提供原导入目标,无法验证真实落点",
apperrors.WithOperation("doc.import"),
apperrors.WithReason("doc_import_verification_target_required"),
apperrors.WithFailureStage("verify_placement"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions(
"使用原 --folder 或 --workspace 重新执行当前 doc import get 查询",
"若无法确认原目标,请按 nodeId 检查文档位置,并避免重复导入",
),
apperrors.WithDetails(details),
)
}
func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args map[string]any) (string, error) {
if cfg.serverID != "" {
return callMCPToolReturnTextOnServer(ctx, cfg.serverID, toolName, args)
@@ -222,7 +502,7 @@ func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file prep
label = "无扩展名"
}
deps.Out.PrintWarning(fmt.Sprintf(
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入解析出的文档目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
label, cfg.supportedFormatsText))
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
@@ -276,7 +556,14 @@ func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file prep
if err != nil {
return err
}
return deps.Out.PrintJSON(map[string]any{
var verification map[string]any
if cfg.verifyPlacement {
_, verification, err = verifyImportedNodePlacement(ctx, file, "", dentryID)
if err != nil {
return err
}
}
result := map[string]any{
"success": true,
"operation": "上传文件到钉钉文档",
"requested_operation": cfg.operation,
@@ -286,7 +573,13 @@ func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file prep
"format": file.extension,
"dentry_id": dentryID,
"result": commit,
})
}
if cfg.verifyPlacement {
result["verified"] = true
result["target"] = importTargetSummary(file)
result["verification"] = verification
}
return deps.Out.PrintJSON(result)
}
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
@@ -322,13 +615,13 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if err != nil {
return err
}
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
return runImportUploadFallback(cmd, cfg, file)
}
uploadFallback := cfg.uploadFallback && !cfg.supportedFormats[file.extension]
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
if uploadFallback {
return runImportUploadFallback(cmd, cfg, file)
}
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
@@ -353,6 +646,16 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if ctx == nil {
ctx = context.Background()
}
if cfg.resolveDefaultTarget && file.folder == "" && file.workspace == "" {
if err := resolveDefaultDocImportTarget(ctx, &file); err != nil {
return err
}
}
// 实际执行时,白名单外格式也必须先完成与在线转换路径相同的目标解析,
// 再进入上传回退;这样无显式目标时仍落到“我的文档”,且可执行同一套回读验证。
if uploadFallback {
return runImportUploadFallback(cmd, cfg, file)
}
if !jsonMode {
deps.Out.PrintInfo("[1/4] 创建导入会话...")
@@ -428,7 +731,7 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
if err != nil {
var timeoutErr *importPollTimeoutError
if !errors.As(err, &timeoutErr) {
return err
return fmt.Errorf("%w;任务已经提交,可使用 %s 继续查询", err, importRecoveryCommand(cfg, taskID, file))
}
if cfg.timeoutAsResult {
if !jsonMode {
@@ -439,15 +742,24 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
"timed_out": true,
"taskId": taskID,
"status": "processing",
"next_command": fmt.Sprintf(cfg.nextCommand, taskID),
"next_command": importRecoveryCommand(cfg, taskID, file),
})
}
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID))
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), importRecoveryCommand(cfg, taskID, file))
}
documentURL, _ := result["documentUrl"].(string)
documentName, _ := result["documentName"].(string)
documentType, _ := result["documentType"].(string)
nodeID := extractNodeIDFromDocURL(documentURL)
var verification map[string]any
if cfg.verifyPlacement {
var err error
nodeID, verification, err = verifyImportedDocumentPlacement(ctx, file, taskID, documentURL)
if err != nil {
return err
}
}
finalResult := map[string]any{
"success": true,
"taskId": taskID,
@@ -456,7 +768,12 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
"documentType": documentType,
}
if cfg.includeNodeID {
finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL)
finalResult["nodeId"] = nodeID
}
if cfg.verifyPlacement {
finalResult["verified"] = true
finalResult["target"] = importTargetSummary(file)
finalResult["verification"] = verification
}
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
@@ -464,20 +781,47 @@ func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) e
return deps.Out.PrintJSON(finalResult)
}
func importRecoveryCommand(cfg importFlowConfig, taskID string, file preparedImportFile) string {
command := fmt.Sprintf(cfg.nextCommand, ShellQuoteArg(taskID))
if !cfg.verifyPlacement {
return command
}
if file.folder != "" {
command += " --folder " + ShellQuoteArg(file.folder)
}
if file.workspace != "" {
command += " --workspace " + ShellQuoteArg(file.workspace)
}
return command
}
func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
taskID := mustGetFlag(cmd, "task-id")
if taskID == "" {
return fmt.Errorf("flag --task-id is required")
}
target := preparedImportFile{
folder: importFlagValue(cmd, cfg.folderFlags...),
workspace: importFlagValue(cmd, cfg.workspaceFlags...),
}
if target.folder != "" {
target.target = "folder_flag"
} else if target.workspace != "" {
target.target = "workspace_flag"
}
if deps.Caller.DryRun() {
if deps.Caller.Format() == "json" {
return deps.Out.PrintJSON(map[string]any{
preview := map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": cfg.queryOperation,
"taskId": taskID,
})
}
if cfg.verifyPlacement {
preview["target"] = importTargetSummary(target)
}
return deps.Out.PrintJSON(preview)
}
deps.Out.PrintKeyValue("操作", cfg.queryOperation)
deps.Out.PrintKeyValue("任务ID", taskID)
@@ -495,15 +839,28 @@ func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
var result map[string]any
if err := json.Unmarshal([]byte(text), &result); err != nil {
deps.Out.PrintRaw(text)
return nil
return fmt.Errorf("解析导入任务响应失败 (taskId=%s),请重试 %s: %w", taskID, importRecoveryCommand(cfg, taskID, target), err)
}
status, _ := result["status"].(string)
message, _ := result["message"].(string)
if strings.EqualFold(status, "completed") {
documentURL, _ := result["documentUrl"].(string)
if cfg.verifyPlacement && target.folder == "" && target.workspace == "" {
return docImportVerificationTargetRequiredError(taskID, documentURL)
}
nodeID := extractNodeIDFromDocURL(documentURL)
if cfg.verifyPlacement {
var verification map[string]any
nodeID, verification, err = verifyImportedDocumentPlacement(ctx, target, taskID, documentURL)
if err != nil {
return err
}
result["verified"] = true
result["target"] = importTargetSummary(target)
result["verification"] = verification
}
if cfg.includeNodeID {
documentURL, _ := result["documentUrl"].(string)
result["nodeId"] = extractNodeIDFromDocURL(documentURL)
result["nodeId"] = nodeID
}
return deps.Out.PrintJSON(result)
}
@@ -64,6 +64,7 @@ func TestCrossPlatformCoverageImportFlowRemainingBranches(t *testing.T) {
t.Cleanup(func() { os.Args = oldArgs })
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"wikiSpaces":[{"workspaceId":"my-space"}]}`},
{text: `{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
{text: `{"taskId":"task-1"}`},
{text: `{"status":"processing"}`},
@@ -76,7 +77,7 @@ func TestCrossPlatformCoverageImportFlowRemainingBranches(t *testing.T) {
cfg.poll.interval = func(int) time.Duration { return 0 }
cfg.poll.wait = func(context.Context, time.Duration) error { return nil }
err := runImportCommand(importCoverageCommand(t, writeImportFixture(t, "md")), nil, cfg)
if err == nil || !strings.Contains(err.Error(), "手动查询") {
if err == nil || !strings.Contains(err.Error(), "手动查询") || !strings.Contains(err.Error(), "--workspace my-space") {
t.Fatalf("runImportCommand() error = %v, want manual-query timeout", err)
}
})
@@ -89,6 +90,8 @@ func TestCrossPlatformCoverageImportFlowRemainingBranches(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"status":"processing"}`}}})
cmd := &cobra.Command{Use: "get"}
cmd.Flags().String("task-id", "task-1", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "workspace-1", "")
if err := runImportGetCommand(cmd, docImportFlowConfig()); err != nil {
t.Fatalf("runImportGetCommand() error = %v", err)
}
+76 -10
View File
@@ -23,6 +23,7 @@ import (
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
@@ -56,6 +57,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
uploadSteps := []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
{text: `{"nodeId":"node-1","workspaceId":"ws-1","folderId":"folder-abc","name":"sales.html"}`},
}
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
@@ -74,20 +76,20 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
if caller.calls != 3 || caller.tool != "get_document_info" {
t.Fatalf("fallback calls = %d last tool = %q, want readback after commit", caller.calls, caller.tool)
}
if got := caller.args["workspaceId"]; got != "ws-1" {
if got := caller.argsLog[1]["workspaceId"]; got != "ws-1" {
t.Fatalf("commit workspaceId = %v, want ws-1", got)
}
if got := caller.args["name"]; got != "sales.html" {
if got := caller.argsLog[1]["name"]; got != "sales.html" {
t.Fatalf("commit name = %v, want original file name with extension", got)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
}
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false || payload["verified"] != true {
t.Fatalf("fallback markers missing: %#v", payload)
}
if payload["dentry_id"] != "node-1" {
@@ -104,6 +106,63 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
}
})
t.Run("fallback resolves and verifies the default personal workspace", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"wikiSpaces":[{"workspaceId":"my-space"}]}`},
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-default","name":"sales.pdf"}`},
{text: `{"nodeId":"node-default","workspaceId":"my-space","name":"sales.pdf"}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "pdf"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
if strings.Join(caller.toolLog, ",") != "list_wikiSpaces,get_file_upload_info,commit_uploaded_file,get_document_info" {
t.Fatalf("fallback calls = %#v", caller.toolLog)
}
if got := caller.argsLog[2]["workspaceId"]; got != "my-space" {
t.Fatalf("default commit workspaceId = %#v", got)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
target, _ := payload["target"].(map[string]any)
if payload["verified"] != true || target["source"] != "default_personal_workspace" || target["workspaceId"] != "my-space" {
t.Fatalf("fallback result = %#v", payload)
}
})
t.Run("fallback does not report success when placement readback mismatches", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-mismatch"}`},
{text: `{"nodeId":"node-mismatch","workspaceId":"wrong-space"}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "pdf"))
_ = cmd.Flags().Set("workspace", "expected-space")
err := runImportCommand(cmd, nil, docImportFlowConfig())
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Reason != "doc_import_placement_unverified" {
t.Fatalf("placement mismatch error = %#v", err)
}
if stdout.Len() != 0 {
t.Fatalf("placement mismatch emitted success output: %s", stdout.String())
}
})
t.Run("json dry run stays a single json document", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
installScriptedCaller(t, caller)
@@ -134,11 +193,12 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
if caller.tool != "get_document_info" {
t.Fatalf("%s last tool = %q, want get_document_info", ext, caller.tool)
}
}
})
@@ -151,11 +211,12 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
path := writeImportFixture(t, "HTM")
cmd := htmlFallbackCommand(t, "")
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
if caller.tool != "get_document_info" {
t.Fatalf("last tool = %q, want get_document_info", caller.tool)
}
})
@@ -172,7 +233,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if got := caller.args["folderId"]; got != "folder-abc" {
if got := caller.argsLog[1]["folderId"]; got != "folder-abc" {
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
}
})
@@ -190,6 +251,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, noExt)
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
@@ -298,6 +360,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
_ = cmd.Flags().Set("workspace", "ws-1")
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
@@ -326,6 +389,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
_ = cmd.Flags().Set("workspace", "ws-1")
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
@@ -338,6 +402,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
{text: `{"fileId":"nested-node-9","workspaceId":"ws-1"}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
@@ -346,6 +411,7 @@ func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
_ = cmd.Flags().Set("workspace", "ws-1")
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
+2 -1
View File
@@ -72,7 +72,8 @@ func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command
out := make([]*cobra.Command, 0, len(factories))
for _, factory := range factories {
handler := factory()
out = append(out, handler.Command(runner))
command := handler.Command(runner)
out = append(out, command)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Use < out[j].Use

Some files were not shown because too many files have changed in this diff Show More