Compare commits
253
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d837da5f3a | ||
|
|
7913c3a720 | ||
|
|
76618a6b8c | ||
|
|
7ab86b82f4 | ||
|
|
5c1bef743a | ||
|
|
1260c188e1 | ||
|
|
f9a6981232 | ||
|
|
2b4187aff8 | ||
|
|
7a9bac0a05 | ||
|
|
a316222584 | ||
|
|
20ee2cc4ee | ||
|
|
a584c18dc2 | ||
|
|
1de179cbdf | ||
|
|
ff6cf48df6 | ||
|
|
879054000d | ||
|
|
4f9cb2ac9f | ||
|
|
f50f086271 | ||
|
|
0fe9dd8ba0 | ||
|
|
6449ad33c1 | ||
|
|
c77046531e | ||
|
|
797766ebf7 | ||
|
|
7f353b73a5 | ||
|
|
be52ca5d2a | ||
|
|
d772570f4a | ||
|
|
7e1d595036 | ||
|
|
47ef4274cb | ||
|
|
6134d413f5 | ||
|
|
529d1f9682 | ||
|
|
8ed46b2da2 | ||
|
|
1ba6bec8c1 | ||
|
|
4a4062d09e | ||
|
|
0e09d23223 | ||
|
|
4815fcc7fc | ||
|
|
15a7b0a0a7 | ||
|
|
27e7f1e1f2 | ||
|
|
e9ee516439 | ||
|
|
617b780a76 | ||
|
|
09b0a59949 | ||
|
|
aa74779aa6 | ||
|
|
130b57de4d | ||
|
|
5a9328ac44 | ||
|
|
0dc21e8c36 | ||
|
|
4423af1af2 | ||
|
|
b45eba0f5c | ||
|
|
c629e1e3eb | ||
|
|
0df2d6d630 | ||
|
|
aeb1b2ced2 | ||
|
|
e3124ccea1 | ||
|
|
3030faf73d | ||
|
|
5605821a70 | ||
|
|
9afe9c1436 | ||
|
|
3fd0d97a26 | ||
|
|
71556a19d9 | ||
|
|
69f1337316 | ||
|
|
01476727e9 | ||
|
|
61ffc988c5 | ||
|
|
156be4cee7 | ||
|
|
2a42e282c7 | ||
|
|
f5378d2f28 | ||
|
|
ea4a463956 | ||
|
|
8d34acbb38 | ||
|
|
9401f9921a | ||
|
|
ed951ff0e1 | ||
|
|
b42596214d | ||
|
|
f684c412ea | ||
|
|
a4813f78bc | ||
|
|
415d962371 | ||
|
|
26253a4eeb | ||
|
|
46fdaed90b | ||
|
|
9ea19315e9 | ||
|
|
8549a90402 | ||
|
|
a5dbd2e049 | ||
|
|
0ebdcb455d | ||
|
|
b1a6f9e20c | ||
|
|
29a0dde9d4 | ||
|
|
0fae9dd8b3 | ||
|
|
6096227511 | ||
|
|
aa265acd50 | ||
|
|
7210e5677c | ||
|
|
1c90623e31 | ||
|
|
e7236e27d4 | ||
|
|
ab883f11f0 | ||
|
|
7101ffc89c | ||
|
|
e73ebab2db | ||
|
|
4381a54efa | ||
|
|
1b6a592387 | ||
|
|
ce7f66ff8e | ||
|
|
e58f255476 | ||
|
|
79f4f66d34 | ||
|
|
66061bb0b0 | ||
|
|
1d14258121 | ||
|
|
cbbc9a9f90 | ||
|
|
723d43f660 | ||
|
|
12d60d264d | ||
|
|
7bb2019dfe | ||
|
|
9e692ce0bf | ||
|
|
e2b390217a | ||
|
|
09ecaaa7a1 | ||
|
|
95ecc99c41 | ||
|
|
6df6b3f8dd | ||
|
|
227861a71d | ||
|
|
20a01aaeff | ||
|
|
4fb3349dcd | ||
|
|
7844b59a8c | ||
|
|
2a875eee1d | ||
|
|
c75eda1a2b | ||
|
|
662352202c | ||
|
|
fa2d54fd42 | ||
|
|
c5d58ec49d | ||
|
|
adbc9aac3e | ||
|
|
82a87bf9ce | ||
|
|
0bfb1ff1de | ||
|
|
426ad50927 | ||
|
|
b57cecb62d | ||
|
|
8dc52d052c | ||
|
|
f8e3f0b12a | ||
|
|
aab67202a0 | ||
|
|
2e60b8e121 | ||
|
|
6147021ae8 | ||
|
|
f84fc1d684 | ||
|
|
ae31d371de | ||
|
|
c20a53cd8a | ||
|
|
de7591be9c | ||
|
|
2dc52afd60 | ||
|
|
21bca3025f | ||
|
|
874b5b091a | ||
|
|
1bb56364c2 | ||
|
|
cd93b7fdc2 | ||
|
|
758b0f875e | ||
|
|
0d8008dabf | ||
|
|
ce8fe16f50 | ||
|
|
1ba326279f | ||
|
|
fdb5fc19f5 | ||
|
|
583d31141d | ||
|
|
19be571574 | ||
|
|
38360969e7 | ||
|
|
f9277ae1d3 | ||
|
|
232d9dee6e | ||
|
|
c2528f1fc8 | ||
|
|
1b22b79fa9 | ||
|
|
f3e98d55e8 | ||
|
|
b5a287ae71 | ||
|
|
da6f867dfa | ||
|
|
82dc2b5e5e | ||
|
|
9265fd4cb8 | ||
|
|
e324ef9d4a | ||
|
|
fb1847d62e | ||
|
|
331681e82b | ||
|
|
1633888290 | ||
|
|
206f33ae1c | ||
|
|
9259477372 | ||
|
|
137151b38c | ||
|
|
081220f15e | ||
|
|
b8216380de | ||
|
|
83bc213b7f | ||
|
|
804b9a3142 | ||
|
|
cfdb0d0556 | ||
|
|
d8686122ab | ||
|
|
222a0230ae | ||
|
|
f7befc7943 | ||
|
|
1dc924af1b | ||
|
|
0e8d6e00cf | ||
|
|
0028ed1570 | ||
|
|
8b5d9a59b0 | ||
|
|
01d663f597 | ||
|
|
2bf314c625 | ||
|
|
8c98d1abe4 | ||
|
|
5e4a65513b | ||
|
|
4ae0e0ffc3 | ||
|
|
1c9a977d08 | ||
|
|
78fabec4bb | ||
|
|
6d6404993a | ||
|
|
008d50bb3d | ||
|
|
f871689960 | ||
|
|
b15a21de93 | ||
|
|
58e8e35948 | ||
|
|
a8b0d8895b | ||
|
|
546c2d2eb2 | ||
|
|
5bd0ea7c53 | ||
|
|
4833b39071 | ||
|
|
8a4e49dbf2 | ||
|
|
7c924c54ca | ||
|
|
59d0b6dd75 | ||
|
|
74f7bbc980 | ||
|
|
2b7d5a2c5f | ||
|
|
fcfead71cb | ||
|
|
0beb1c6b0c | ||
|
|
3b38d4c8da | ||
|
|
d68e340a5b | ||
|
|
98799effba | ||
|
|
9239f9070a | ||
|
|
202c5ce697 | ||
|
|
8ab2ac5e7c | ||
|
|
b85a342e9f | ||
|
|
ad72cf4b3d | ||
|
|
89154b3952 | ||
|
|
b01febf52e | ||
|
|
74b7690cbb | ||
|
|
8312c4f30e | ||
|
|
35c6fd95e1 | ||
|
|
564ff8563f | ||
|
|
c7510cd1a1 | ||
|
|
1c3477c087 | ||
|
|
93d450a9bf | ||
|
|
cf39768095 | ||
|
|
c096258b0f | ||
|
|
5ba8ac6775 | ||
|
|
66fee5ef5f | ||
|
|
d9b9c5c7da | ||
|
|
684411e54e | ||
|
|
de5ba029d4 | ||
|
|
15f139e32d | ||
|
|
768c1ce494 | ||
|
|
966fd60e2f | ||
|
|
7825c3c7e0 | ||
|
|
10d44615d1 | ||
|
|
1e88612e43 | ||
|
|
5934ccac7f | ||
|
|
d9365f3fff | ||
|
|
15d93698bd | ||
|
|
7e69a3d6fe | ||
|
|
94b4958038 | ||
|
|
97a99ba04f | ||
|
|
5e1e5cbb84 | ||
|
|
e04e886c50 | ||
|
|
1acde9b766 | ||
|
|
852efe56aa | ||
|
|
6c287bcb4d | ||
|
|
df24d53886 | ||
|
|
49cecabb12 | ||
|
|
09993ad82c | ||
|
|
0efaf6c82f | ||
|
|
26002637f4 | ||
|
|
f7229091ae | ||
|
|
77aa813467 | ||
|
|
1f595571c0 | ||
|
|
49ab53ea0d | ||
|
|
1308d08862 | ||
|
|
d552c59d11 | ||
|
|
6d9781fe15 | ||
|
|
c790fe3c3b | ||
|
|
e841d41640 | ||
|
|
470aa42d7b | ||
|
|
a74d96bb96 | ||
|
|
9798a60728 | ||
|
|
7b5e3e2d1f | ||
|
|
b10da77e09 | ||
|
|
30782020ad | ||
|
|
d87cdef00b | ||
|
|
999e7a7b9d | ||
|
|
94cee4388e | ||
|
|
dcc7e72ec1 | ||
|
|
5a001f33b6 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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: Added
|
||||
---
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: 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.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **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.
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Code Admission — AI Behavior
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled, unlabeled]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -12,6 +12,12 @@ permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
concurrency:
|
||||
# State transitions for one PR supersede older policy evaluations. This
|
||||
# prevents a stale Ready run from overwriting the fail-closed Draft status.
|
||||
group: ai-behavior-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior
|
||||
@@ -47,23 +53,37 @@ jobs:
|
||||
try {
|
||||
const expectedHead = pullRequest.head.sha;
|
||||
const expectedBase = pullRequest.base.sha;
|
||||
const expectedDraft = pullRequest.draft;
|
||||
const currentPull = async (phase) => {
|
||||
const { data: pull } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) {
|
||||
if (
|
||||
pull.head.sha !== expectedHead ||
|
||||
pull.base.sha !== expectedBase ||
|
||||
pull.draft !== expectedDraft
|
||||
) {
|
||||
throw new Error(
|
||||
`Pull request revision changed during ${phase}: ` +
|
||||
`expected base/head ${expectedBase}/${expectedHead}, ` +
|
||||
`got ${pull.base.sha}/${pull.head.sha}`
|
||||
`Pull request identity changed during ${phase}: ` +
|
||||
`expected base/head/draft ${expectedBase}/${expectedHead}/${expectedDraft}, ` +
|
||||
`got ${pull.base.sha}/${pull.head.sha}/${pull.draft}`
|
||||
);
|
||||
}
|
||||
return pull;
|
||||
};
|
||||
|
||||
const before = await currentPull('pre-policy check');
|
||||
if (before.draft) {
|
||||
await setStatus('failure', 'Draft PR; mark Ready to run Code Admission');
|
||||
core.setFailed(
|
||||
'Draft pull requests are not eligible for Code Admission. ' +
|
||||
'Mark the pull request ready to run all nine required contexts.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = before.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
|
||||
+473
-7
@@ -5,17 +5,26 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, 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:
|
||||
lint:
|
||||
name: Lint
|
||||
# Draft revisions use the isolated Draft CI workflow. Skip the heavy
|
||||
# admission graph; the base-owned AI Behavior status fails Draft revisions
|
||||
# so these successful skipped checks cannot authorize a merge.
|
||||
if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
@@ -544,7 +553,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
@@ -692,7 +702,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
@@ -835,7 +846,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:
|
||||
@@ -914,6 +927,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
|
||||
@@ -1244,7 +1547,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
|
||||
@@ -1379,6 +1682,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:
|
||||
@@ -1387,6 +1827,7 @@ jobs:
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-main-metadata
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
@@ -1403,6 +1844,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: |
|
||||
@@ -1411,10 +1853,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
|
||||
@@ -1430,7 +1876,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#*:}"
|
||||
@@ -1570,6 +2017,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: |
|
||||
@@ -1597,7 +2060,10 @@ jobs:
|
||||
name: Policy
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Full policy regenerates and validates the runtime Schema several times.
|
||||
# Keep job-level headroom for large reviewed command-surface additions;
|
||||
# individual policy gates retain their own fail-closed checks.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Draft CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Draft validation is feedback, not a cache producer. Keep only the newest
|
||||
# revision for one PR so rapid pushes do not compete with admission runs.
|
||||
# A title/body-only edited event gets a unique no-op group, so its skipped
|
||||
# job cannot cancel useful validation already running for the same revision.
|
||||
group: draft-ci-${{ github.event.action == 'edited' && github.event.changes.base == null && format('noop-{0}', github.run_id) || format('pr-{0}', github.event.pull_request.number) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
draft-fast:
|
||||
name: Draft Fast Gate
|
||||
# An edited event is relevant only when the base branch changed. Title and
|
||||
# body edits must not spend a runner or replace useful feedback.
|
||||
if: >-
|
||||
github.event.pull_request.draft == true &&
|
||||
(github.event.action != 'edited' || github.event.changes.base != null)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out authoritative synthetic merge
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify draft revision identity
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
|
||||
echo "draft merge first parent does not match event base" >&2
|
||||
exit 1
|
||||
}
|
||||
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
|
||||
echo "draft merge second parent does not match event head" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify test package plan
|
||||
run: make test-plan
|
||||
|
||||
- name: Check formatting
|
||||
run: make format-check
|
||||
|
||||
- name: Run Go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Check GitHub Actions workflows
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
- name: Test reviewer routing policy
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
- name: Test npm installer smoke
|
||||
env:
|
||||
XDG_CONFIG_HOME: ""
|
||||
run: node test/scripts/install_js_smoke.mjs
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Validate lightweight repository policy
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
git diff --check "$PR_BASE_SHA" HEAD
|
||||
./scripts/policy/check-open-source-assets.sh
|
||||
./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
|
||||
if git diff --quiet "$PR_BASE_SHA" HEAD -- CHANGELOG.md; then
|
||||
exit 0
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
--content-only "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Test CI workflow contracts
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: >-
|
||||
go test -count=1 ./test/scripts
|
||||
-run '^(TestDraftPRCIWorkflowContract|TestChangelogPRFastPathWorkflowContract)$'
|
||||
|
||||
- name: Record admission boundary
|
||||
run: |
|
||||
printf '%s\n' \
|
||||
'Draft Fast Gate is development feedback, not Code Admission.' \
|
||||
'AI Behavior remains failed until this PR is Ready and fully admitted.' \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -14,9 +14,12 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
const webhooks = [
|
||||
process.env.DINGTALK_WEBHOOK,
|
||||
process.env.DINGTALK_WEBHOOK_SECONDARY
|
||||
].filter(Boolean);
|
||||
if (webhooks.length === 0) {
|
||||
console.log('⚠️ No DingTalk webhook configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +42,15 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
|
||||
console.log('✅ DingTalk notification sent');
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.2] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -20,6 +20,7 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
shortcut-public-e2e-proof: build
|
||||
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -84,7 +88,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat
|
||||
policy: test-auth-legacy-compat shortcut-public-e2e-proof
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
+63
-5
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
// Interactive commands must remain in the terminal's foreground session so
|
||||
// prompts can use /dev/tty. Non-interactive launches use a separate process
|
||||
// group, allowing a signal sent only to this wrapper to reach the full vendor
|
||||
// process tree exactly once.
|
||||
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
|
||||
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
let spawnFailed = false;
|
||||
let forwardedSignal = null;
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM"];
|
||||
|
||||
function forwardSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (!isolateVendorProcessGroup) {
|
||||
// Ctrl-C is generated for the whole foreground process group, including
|
||||
// the vendor. SIGTERM is not terminal-generated and still needs an
|
||||
// explicit handoff when a process manager targets only this wrapper.
|
||||
if (signal === "SIGTERM") {
|
||||
child.kill(signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// detached makes the vendor PID the leader of its POSIX process group.
|
||||
// Signal the whole group so any subprocesses inherit the same shutdown.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
// The group may have completed between the state check and kill.
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
const signalHandlers = new Map(
|
||||
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
|
||||
);
|
||||
for (const signal of forwardedSignals) {
|
||||
process.on(signal, signalHandlers.get(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
spawnFailed = true;
|
||||
console.error(error.message);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
for (const forwarded of forwardedSignals) {
|
||||
process.removeListener(forwarded, signalHandlers.get(forwarded));
|
||||
}
|
||||
|
||||
const exitSignal = forwardedSignal || signal;
|
||||
if (exitSignal && process.platform !== "win32") {
|
||||
process.kill(process.pid, exitSignal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = spawnFailed || code === null ? 1 : code;
|
||||
});
|
||||
|
||||
+215
-4
@@ -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),
|
||||
|
||||
+219
-12
@@ -22,8 +22,38 @@ the nine contracts above.
|
||||
`AI Behavior` is evaluated by a `pull_request_target` workflow that never
|
||||
checks out or executes PR code. It writes the exact `AI Behavior` status to the
|
||||
current PR head. Its Files API read is bracketed by base/head revision checks,
|
||||
so a synchronize race fails closed. The same workflow supplies a successful
|
||||
`AI Behavior` check run on protected `main` pushes for release governance.
|
||||
and Ready/Draft state transitions cancel older evaluations and verify the live
|
||||
state, so a synchronize or state race fails closed. A Draft revision publishes
|
||||
an explicit failing `AI Behavior` status; marking it Ready first replaces that
|
||||
status with `pending` and only then evaluates the normal policy. The same
|
||||
workflow supplies a successful `AI Behavior` check run on protected `main`
|
||||
pushes for release governance.
|
||||
|
||||
## Draft pull-request feedback
|
||||
|
||||
A Draft pull request runs the independent `Draft CI` workflow. Its single
|
||||
`Draft Fast Gate` provides bounded development feedback: it verifies the
|
||||
synthetic merge identity, package plan, formatting, `go vet`, Actions syntax,
|
||||
reviewer routing, installer smoke, build, release-fragment lifecycle, and
|
||||
lightweight repository policy.
|
||||
|
||||
The Draft result is not Code Admission and is never a substitute for `Lint`,
|
||||
`Test`, `Coverage`, `Policy`, `Edition`, `Interface Integrity`, `AI Behavior`,
|
||||
`CLI Smoke`, or `Mock MCP`. GitHub records conditionally skipped formal jobs as
|
||||
successful checks, so absence alone is not a safe admission boundary. The
|
||||
base-owned `AI Behavior` status therefore fails every Draft revision explicitly;
|
||||
because it is one of the nine required contexts, skipped formal jobs cannot
|
||||
satisfy the ruleset. Marking the pull request Ready changes that status to
|
||||
`pending` before policy evaluation and starts complete tier-selected admission
|
||||
on the current head SHA. Merge remains blocked until all nine contexts succeed.
|
||||
|
||||
Converting a ready pull request back to Draft creates a new skipped formal CI
|
||||
run in the same PR concurrency group, cancelling any in-progress heavy
|
||||
admission work; it also replaces `AI Behavior` with a failing Draft status and
|
||||
starts `Draft Fast Gate`. A later `ready_for_review` event cancels any remaining
|
||||
Draft validation, marks `AI Behavior` pending, and runs full admission again.
|
||||
Editing only a Draft title or body does not consume a runner; changing its base
|
||||
branch revalidates the synthetic merge.
|
||||
|
||||
## Exact CHANGELOG-only fast path
|
||||
|
||||
@@ -147,13 +177,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 +342,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 +444,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 +465,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.
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# AI 表格数据源指令使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
|
||||
|
||||
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
|
||||
|
||||
## 指令速览
|
||||
|
||||
| 指令 | 用途 | 读写 | 风险 |
|
||||
|------|------|------|------|
|
||||
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
|
||||
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
|
||||
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
|
||||
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
|
||||
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
|
||||
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **登录认证**:执行 `dws auth login` 确保已登录
|
||||
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
|
||||
|
||||
---
|
||||
|
||||
## 1. 列出数据源可用来源
|
||||
|
||||
```
|
||||
dws aitable +datasource-list-sources [flags]
|
||||
```
|
||||
|
||||
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回 `sources` 数组,每个条目包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
|
||||
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
|
||||
| `sourceUrl` | 数据源访问链接,可选 |
|
||||
|
||||
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取数据源可同步字段
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-fields [flags]
|
||||
```
|
||||
|
||||
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取某审批模板的可同步字段
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建数据源表
|
||||
|
||||
```
|
||||
dws aitable +datasource-create [flags]
|
||||
```
|
||||
|
||||
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
### source-config 格式(审批类)
|
||||
|
||||
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
|
||||
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
|
||||
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
|
||||
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
|
||||
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
|
||||
|
||||
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
|
||||
|
||||
按 `dataType` 选择对应的时间参数组合:
|
||||
|
||||
| dataType | 需要的时间字段 | 说明 |
|
||||
|----------|----------------|------|
|
||||
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
|
||||
| `start_time` | `startDate` | 同步从某日期至今的数据 |
|
||||
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
|
||||
|
||||
### auto-sync-setting 格式
|
||||
|
||||
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
|
||||
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
|
||||
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
|
||||
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 基本创建——同步近 30 天审批数据
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 指定日期范围创建并开启自动同步
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--auto
|
||||
|
||||
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--field-ids fldAAA,fldBBB,fldCCC
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
|
||||
|
||||
---
|
||||
|
||||
## 4. 更新数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-update [flags]
|
||||
```
|
||||
|
||||
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
|
||||
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 更换审批模板并调整时间范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 更新同步字段范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--field-ids fldAAA,fldDDD
|
||||
```
|
||||
|
||||
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 触发手动同步
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync [flags]
|
||||
```
|
||||
|
||||
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 同步单张表
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1
|
||||
|
||||
# 批量同步多张表(逗号分隔,最多 5 个)
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1,TBL2,TBL3
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询同步状态
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync-status [flags]
|
||||
```
|
||||
|
||||
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 按任务 ID 查询(批量,最多 5 个)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK1,TASK2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 获取数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-config [flags]
|
||||
```
|
||||
|
||||
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景一:从零接入审批数据
|
||||
|
||||
```bash
|
||||
# 0. 获取 Base ID
|
||||
dws aitable +base-search --query "我的项目表"
|
||||
|
||||
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
|
||||
|
||||
# 2. 查看可同步字段(可选,用于指定 field-ids)
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 3. 创建数据源表(创建后自动触发首次同步)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
# → 返回 tableId=TBL456, taskId=TASK001
|
||||
|
||||
# 4. 查询首次同步是否完成
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
|
||||
# 5. 确认配置
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
### 场景二:更换审批模板后重新同步
|
||||
|
||||
```bash
|
||||
# 1. 更新源配置(更新后自动触发一次同步)
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 2. 查询同步状态(更新后会返回新的 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK002
|
||||
```
|
||||
|
||||
### 场景三:手动触发日常同步
|
||||
|
||||
```bash
|
||||
# 仅触发同步,不修改配置
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL456
|
||||
|
||||
# 查询结果(sync 会返回 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
```
|
||||
|
||||
### 场景四:开启自动同步后确认
|
||||
|
||||
```bash
|
||||
# 1. 更新配置,开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 2. 确认配置已更新
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
# → 返回中应显示 auto=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用选项
|
||||
|
||||
以下全局选项可在所有指令中使用:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
|
||||
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
|
||||
| `--fields` | 筛选输出字段(逗号分隔) |
|
||||
| `--dry-run` | 预览操作内容,不实际执行 |
|
||||
| `--profile` | 指定组织或账号 |
|
||||
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
|
||||
| `--debug` | 显示调试日志 |
|
||||
| `-v, --verbose` | 显示详细日志 |
|
||||
|
||||
### 输出过滤示例
|
||||
|
||||
```bash
|
||||
# 只取 tableId
|
||||
dws aitable +datasource-create ... --jq '.tableId'
|
||||
|
||||
# 只取同步状态
|
||||
dws aitable +datasource-sync-status ... --jq '.status'
|
||||
|
||||
# table 格式查看
|
||||
dws aitable +datasource-get-config ... -f table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
|
||||
|
||||
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
|
||||
|
||||
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
|
||||
|
||||
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
|
||||
|
||||
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
|
||||
|
||||
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
|
||||
|
||||
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
|
||||
@@ -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` 语义;不是互相否定):
|
||||
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
|
||||
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,198 +0,0 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,7 +1,67 @@
|
||||
{
|
||||
"generated_at": "2026-08-20T11:51:44.156046",
|
||||
"count": 416,
|
||||
"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",
|
||||
"command": "+search-person",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -272,6 +332,76 @@
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-config",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-list-sources",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync-status",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -2263,235 +2393,384 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+by-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-followings",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-role-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-sub-depts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+lookup",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+me",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+org",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+resolve-dept",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+team",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
|
||||
"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",
|
||||
@@ -3593,6 +3872,16 @@
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
@@ -3634,18 +3923,24 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+list-sheets",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+read",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3857,6 +4152,26 @@
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+query",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+update",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
hasTarget := false
|
||||
for _, id := range subscribeIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
hasTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
|
||||
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
t.Fatal("targeted stop called without a subscribe_id")
|
||||
return transport.ConsumerStopResp{}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried without a subscribe_id")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
|
||||
t.Fatalf("empty target stop = %v", err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
|
||||
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
|
||||
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
|
||||
}
|
||||
|
||||
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
registered := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "markdown" {
|
||||
registered++
|
||||
}
|
||||
}
|
||||
if registered != 0 {
|
||||
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
|
||||
}
|
||||
|
||||
type route struct {
|
||||
canonical string
|
||||
confirmation string
|
||||
flags []string
|
||||
}
|
||||
routes := map[string]route{
|
||||
"create": {
|
||||
canonical: "markdown.create", confirmation: "not_required",
|
||||
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
|
||||
},
|
||||
"fetch": {
|
||||
canonical: "markdown.fetch", confirmation: "not_required",
|
||||
flags: []string{"node", "output", "space-id", "workspace"},
|
||||
},
|
||||
"overwrite": {
|
||||
canonical: "markdown.overwrite", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
|
||||
},
|
||||
"patch": {
|
||||
canonical: "markdown.patch", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
|
||||
},
|
||||
"diff": {
|
||||
canonical: "markdown.diff", confirmation: "not_required",
|
||||
flags: []string{"context", "file", "node", "version", "version2"},
|
||||
},
|
||||
}
|
||||
|
||||
group := mustFindCommand(t, root, "markdown")
|
||||
children := map[string]bool{}
|
||||
for _, child := range group.Commands() {
|
||||
children[child.Name()] = true
|
||||
}
|
||||
if len(children) != len(routes) {
|
||||
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
|
||||
}
|
||||
|
||||
for name, want := range routes {
|
||||
leaf := mustFindCommand(t, root, "markdown", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !children[name] {
|
||||
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
|
||||
}
|
||||
for _, flag := range want.flags {
|
||||
if leaf.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
|
||||
}
|
||||
}
|
||||
if shortcut.InPublicCatalog("markdown", "+"+name) {
|
||||
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("markdown " + name)
|
||||
if !ok {
|
||||
t.Errorf("markdown %s missing from assembled Schema", name)
|
||||
continue
|
||||
}
|
||||
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
|
||||
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
|
||||
}
|
||||
if meta.Safety.Confirmation != want.confirmation {
|
||||
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
|
||||
}
|
||||
|
||||
tool := tools[want.canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("markdown %s missing from full delivery Schema", name)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("markdown %s availability=%q, want available", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
driveShortcuts := map[string]string{
|
||||
"+copy": "drive.shortcut_copy",
|
||||
"+delete": "drive.shortcut_delete",
|
||||
"+find-file": "drive.shortcut_find_file",
|
||||
"+list": "drive.shortcut_list",
|
||||
"+move": "drive.shortcut_move",
|
||||
"+publish-get": "drive.shortcut_publish_get",
|
||||
"+recycle-restore": "drive.shortcut_recycle_restore",
|
||||
"+rename": "drive.shortcut_rename",
|
||||
"+version-download": "drive.shortcut_version_download",
|
||||
"+version-get": "drive.shortcut_version_get",
|
||||
"+version-history": "drive.shortcut_version_history",
|
||||
"+version-revert": "drive.shortcut_version_revert",
|
||||
}
|
||||
for name, canonical := range driveShortcuts {
|
||||
leaf := mustFindCommand(t, root, "drive", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !shortcut.InPublicCatalog("drive", name) {
|
||||
t.Errorf("drive %s is not in the public Shortcut catalog", name)
|
||||
}
|
||||
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
|
||||
}
|
||||
|
||||
ordinaryRoutes := map[string]string{
|
||||
"drive permission list": "drive.list_permission",
|
||||
"drive pull": "drive.folder_pull",
|
||||
"drive push": "drive.folder_push",
|
||||
"drive status": "drive.folder_status",
|
||||
"drive sync": "drive.folder_sync",
|
||||
"wiki node list": "wiki.list_nodes",
|
||||
}
|
||||
for cliPath, canonical := range ordinaryRoutes {
|
||||
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
|
||||
t.Helper()
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
|
||||
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
|
||||
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
|
||||
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
|
||||
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
@@ -221,8 +221,8 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
|
||||
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
|
||||
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
@@ -231,12 +231,189 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for the
|
||||
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
|
||||
// separate map lets this test file land before the draft replaces the formal
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for
|
||||
// reviewed parameter-concept product drafts. Keeping candidate-only commands
|
||||
// in a separate map lets a test change land before a draft replaces the formal
|
||||
// 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"},
|
||||
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
|
||||
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
|
||||
"contact +org": {"contact", "+org", "--name", "Fixture User"},
|
||||
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
|
||||
"contact +team": {"contact", "+team", "--name", "Fixture User"},
|
||||
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
|
||||
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
|
||||
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
|
||||
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
|
||||
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
|
||||
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
|
||||
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
|
||||
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
|
||||
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
|
||||
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
|
||||
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
|
||||
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
|
||||
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
|
||||
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
|
||||
"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"},
|
||||
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
|
||||
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
|
||||
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
|
||||
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
|
||||
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
|
||||
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
|
||||
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
|
||||
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
|
||||
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
|
||||
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
|
||||
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
|
||||
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
|
||||
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
|
||||
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
|
||||
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
|
||||
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
|
||||
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
|
||||
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
|
||||
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
|
||||
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
|
||||
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
|
||||
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
|
||||
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
|
||||
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
|
||||
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
|
||||
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
|
||||
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
|
||||
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
|
||||
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
|
||||
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
|
||||
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
|
||||
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
|
||||
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
|
||||
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
|
||||
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
|
||||
"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"},
|
||||
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
@@ -296,6 +473,32 @@ 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"},
|
||||
},
|
||||
"markdown diff": {
|
||||
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
|
||||
},
|
||||
"markdown overwrite": {
|
||||
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
|
||||
},
|
||||
"markdown patch": {
|
||||
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
|
||||
},
|
||||
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
@@ -969,6 +1172,123 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// exercises every distinct reviewed mutating complete-command template in the
|
||||
// reviewed product expansions. The fixture gate already proves every
|
||||
// alias resolves through PreParse; this gate removes the confirmation flag
|
||||
// from one active alias invocation per distinct template and requires the
|
||||
// runtime boundary to stop it before the first transport call. An explicit
|
||||
// --dry-run is a reviewed preview path and must not carry a bypass flag.
|
||||
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
requiredTemplates := make(map[string]bool)
|
||||
coveredTemplates := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
|
||||
continue
|
||||
}
|
||||
product, _, _ := strings.Cut(fixture.Command, " ")
|
||||
switch product {
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat",
|
||||
"agoal", "audit", "dev", "devapp", "event", "mcp", "recruit":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, yesCount := removeExactArg(complete, "--yes")
|
||||
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
|
||||
confirmationCount := yesCount + userSayYesCount
|
||||
confirmationArg := "--yes"
|
||||
if userSayYesCount == 1 {
|
||||
confirmationArg = "--user-say-yes"
|
||||
}
|
||||
_, dryRunCount := removeExactArg(complete, "--dry-run")
|
||||
if dryRunCount > 1 {
|
||||
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
|
||||
switch meta.Safety.Confirmation {
|
||||
case "user_required":
|
||||
if dryRunCount == 1 {
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
case "not_required":
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if confirmationCount == 0 {
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
|
||||
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
|
||||
requiredTemplates[templateKey] = true
|
||||
if coveredTemplates[templateKey] {
|
||||
continue
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
|
||||
continue
|
||||
}
|
||||
coveredTemplates[templateKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
if len(requiredTemplates) == 0 {
|
||||
t.Fatal("reviewed complete-command templates contain no confirmation cases")
|
||||
}
|
||||
if len(coveredTemplates) != len(requiredTemplates) {
|
||||
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
|
||||
}
|
||||
}
|
||||
|
||||
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
@@ -1271,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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-10
@@ -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"
|
||||
@@ -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...)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"sheet.create_float_image",
|
||||
"sheet.update_float_image",
|
||||
)
|
||||
|
||||
create := payload.Tools["sheet.create_float_image"]
|
||||
if create == nil {
|
||||
t.Fatal("missing sheet.create_float_image")
|
||||
}
|
||||
if create["interface_mode"] != "mcp" {
|
||||
t.Fatalf("create interface mode = %#v", create["interface_mode"])
|
||||
}
|
||||
createRef, _ := create["interface_ref"].(map[string]any)
|
||||
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
|
||||
t.Fatalf("create interface ref = %#v", createRef)
|
||||
}
|
||||
createDryRun, _ := create["dry_run"].(map[string]any)
|
||||
if createDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("create dry-run = %#v", createDryRun)
|
||||
}
|
||||
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
|
||||
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
|
||||
}
|
||||
createParameters, _ := create["parameters"].(map[string]any)
|
||||
file, _ := createParameters["file"].(map[string]any)
|
||||
src, _ := createParameters["src"].(map[string]any)
|
||||
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
|
||||
t.Fatalf("create --file metadata = %#v", file)
|
||||
}
|
||||
if schemaContractString(file["property"]) != "" {
|
||||
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
|
||||
}
|
||||
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
|
||||
t.Fatalf("create --src compatibility metadata = %#v", src)
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
|
||||
|
||||
update := payload.Tools["sheet.update_float_image"]
|
||||
if update == nil {
|
||||
t.Fatal("missing sheet.update_float_image")
|
||||
}
|
||||
updateDryRun, _ := update["dry_run"].(map[string]any)
|
||||
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
|
||||
}
|
||||
updateParameters, _ := update["parameters"].(map[string]any)
|
||||
updateFile, _ := updateParameters["file"].(map[string]any)
|
||||
if schemaContractString(updateFile["property"]) != "" {
|
||||
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil || command.Flags().Lookup("file") == nil {
|
||||
t.Fatalf("%s has no executable --file flag", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 415
|
||||
publicShortcutCount = 436
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 453
|
||||
schemaPublishedShortcutCount = 493
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 415
|
||||
publiclyDeliveredShortcutCount = 436
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -271,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(declared.Command, "+datasource-") {
|
||||
continue
|
||||
}
|
||||
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if !strings.Contains(example, "--source-config") {
|
||||
continue
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
sourceConfig := schemaExampleFlagValue(argv, "source-config")
|
||||
if sourceConfig == "" {
|
||||
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
|
||||
continue
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
|
||||
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
|
||||
continue
|
||||
}
|
||||
for _, member := range requiredSourceConfigMembers {
|
||||
if _, ok := cfg[member]; !ok {
|
||||
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
@@ -282,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func schemaExampleFlagValue(argv []string, name string) string {
|
||||
prefix := "--" + name + "="
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+name {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, prefix) {
|
||||
return strings.TrimPrefix(argument, prefix)
|
||||
}
|
||||
}
|
||||
// Value may be in the next argv entry: `--flag value` form.
|
||||
for i := 0; i < len(argv)-1; i++ {
|
||||
if argv[i] == "--"+name {
|
||||
return argv[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
for canonical, command := range map[string]string{
|
||||
"whiteboard.shortcut_query": "+query",
|
||||
"whiteboard.shortcut_update": "+update",
|
||||
} {
|
||||
leaf, _, err := root.Find([]string{"whiteboard", command})
|
||||
if err != nil || leaf == nil || leaf.Name() != command {
|
||||
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
|
||||
} else if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("public %s missing from delivery Schema surface", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability=%q, want available", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("%s missing composite adapter reason", canonical)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
// The organization mirror may be absent for long-lived local logins
|
||||
// that predate mirror publication. Recover from the legacy global
|
||||
// slot before giving up.
|
||||
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
|
||||
return recovered, nil
|
||||
}
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
@@ -891,6 +897,123 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||
return nil, refreshErr
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
|
||||
"corp_id", strings.TrimSpace(selected.CorpID),
|
||||
"user_id", strings.TrimSpace(selected.UserID),
|
||||
"refresh_error_code", exchangeErr.Code,
|
||||
)
|
||||
legacy, loadErr := tokenLoadKeychain()
|
||||
if loadErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
if legacy == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
|
||||
"step", "candidate_mismatch",
|
||||
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
|
||||
"legacy_user_id", strings.TrimSpace(legacy.UserID),
|
||||
)
|
||||
return nil, refreshErr
|
||||
}
|
||||
recovered := *legacy
|
||||
if strings.TrimSpace(recovered.UserID) == "" {
|
||||
recovered.UserID = strings.TrimSpace(selected.UserID)
|
||||
}
|
||||
if strings.TrimSpace(recovered.UserName) == "" {
|
||||
recovered.UserName = strings.TrimSpace(selected.UserName)
|
||||
}
|
||||
if recovered.IsAccessTokenValid() {
|
||||
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
|
||||
return &recovered, nil
|
||||
}
|
||||
if !recovered.IsRefreshTokenValid() {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
|
||||
if recoverErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
|
||||
if selected == nil || legacy == nil {
|
||||
return false
|
||||
}
|
||||
selectedCorpID := strings.TrimSpace(selected.CorpID)
|
||||
legacyCorpID := strings.TrimSpace(legacy.CorpID)
|
||||
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
|
||||
return false
|
||||
}
|
||||
selectedUserID := strings.TrimSpace(selected.UserID)
|
||||
legacyUserID := strings.TrimSpace(legacy.UserID)
|
||||
if legacyUserID != "" {
|
||||
return legacyUserID == selectedUserID
|
||||
}
|
||||
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
|
||||
}
|
||||
|
||||
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil || cfg == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
|
||||
return false
|
||||
}
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) != 1 {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "multi_profile",
|
||||
"corp_id", strings.TrimSpace(corpID),
|
||||
"profile_count", len(profiles),
|
||||
)
|
||||
return false
|
||||
}
|
||||
profile := profiles[0]
|
||||
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
|
||||
return true
|
||||
}
|
||||
profileUserID := ""
|
||||
if profile != nil {
|
||||
profileUserID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "identity_mismatch",
|
||||
"selected_user_id", strings.TrimSpace(userID),
|
||||
"profile_user_id", profileUserID,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
// external host, exchanges it for tokens, and persists them.
|
||||
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "V1039 User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
UserName: selected.UserName,
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
var saved *TokenData
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
|
||||
copy := *data
|
||||
saved = ©
|
||||
return nil
|
||||
})
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
|
||||
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
|
||||
}
|
||||
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
|
||||
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
|
||||
}
|
||||
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
|
||||
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-multi",
|
||||
UserID: "user-v1039-a",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
|
||||
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-blank-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
|
||||
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-user-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token owned by a different user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
expired := *fixture.blankToken
|
||||
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-single-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "Other User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-legacy-refresh",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
refreshed := &TokenData{
|
||||
AccessToken: "refreshed-legacy-access",
|
||||
RefreshToken: "rotated-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
refreshCalls := 0
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
refreshCalls++
|
||||
if refreshCalls == 1 {
|
||||
return nil, rejection
|
||||
}
|
||||
return refreshed, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if refreshCalls != 2 {
|
||||
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
|
||||
}
|
||||
if recovered.AccessToken != refreshed.AccessToken {
|
||||
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
|
||||
}
|
||||
if recovered.RefreshToken != refreshed.RefreshToken {
|
||||
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-profiles-error",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "shared-rejected-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-same-refresh",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: selected.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
|
||||
// nil selected must be rejected before any dereference.
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil selected error = %v, want original rejection", err)
|
||||
}
|
||||
|
||||
// A keychain load that returns (nil, nil) must be rejected before any dereference.
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-nil-legacy",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil legacy error = %v, want original rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
|
||||
|
||||
plainErr := errors.New("plain refresh failure")
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
|
||||
t.Fatalf("plain error = %v, want original plain failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-recovery-steps",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
t.Run("save_failure", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("save failure error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_expired", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "expired-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(-time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("expired refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, errors.New("legacy refresh failed")
|
||||
})
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("legacy refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("preflight error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
|
||||
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
|
||||
t.Fatal("nil legacy accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
|
||||
t.Fatal("different corp accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
|
||||
t.Fatal("blank selected corp accepted")
|
||||
}
|
||||
|
||||
blankSelected := &TokenData{CorpID: selected.CorpID}
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
|
||||
})
|
||||
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
|
||||
t.Fatal("both blank user IDs should match only through the single-profile guard")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
|
||||
t.Fatal("blank selected with non-blank legacy accepted")
|
||||
}
|
||||
|
||||
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
|
||||
t.Fatal("blank corp accepted by single-profile check")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1397
-645
File diff suppressed because one or more lines are too long
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
|
||||
if parameters[flagName]["required"] != false {
|
||||
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
|
||||
}
|
||||
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
if parameters["max-depth"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
|
||||
}
|
||||
if got := parameters["version"]["property"]; got != "historyVersion" {
|
||||
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
|
||||
}
|
||||
if parameters["version"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
|
||||
}
|
||||
if got := parameters["password"]["property"]; got != "password" {
|
||||
t.Fatalf("doc read --password property = %#v, want password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
|
||||
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"agoal scorecard detail",
|
||||
"agoal scorecard entity-detail",
|
||||
"agoal scorecard update",
|
||||
"agoal scorecard search-entities",
|
||||
"agoal strategy detail",
|
||||
"agoal strategy list",
|
||||
"agoal strategy update",
|
||||
|
||||
@@ -96,7 +96,14 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
|
||||
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src"}},
|
||||
})
|
||||
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
|
||||
})
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
|
||||
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
|
||||
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -552,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
|
||||
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -644,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -664,6 +668,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
|
||||
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
|
||||
|
||||
+139
-9
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
|
||||
// typed framework confirmation-gate failure carrying reason
|
||||
// confirmation_required. Downstream classifiers must pass such errors through
|
||||
// verbatim: the "re-run with --yes" semantics can only be carried by the
|
||||
// machine-readable reason, while message-text classification actively
|
||||
// misroutes them (a command path containing "permission" would be reported as
|
||||
// an auth failure, and any other wording degrades to an unclassified error).
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
return strings.TrimSpace(typed.Reason) == "confirmation_required"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithServerKey records the server identifier associated with the failure.
|
||||
func WithServerKey(serverKey string) Option {
|
||||
return func(err *Error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Fatalf("normal mode should not show RPC Code, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if IsConfirmationRequired(nil) {
|
||||
t.Fatal("nil error must not report confirmation_required")
|
||||
}
|
||||
if IsConfirmationRequired(NewValidation("missing required flag")) {
|
||||
t.Fatal("plain validation error must not report confirmation_required")
|
||||
}
|
||||
plain := stderrors.New("需要用户确认")
|
||||
if IsConfirmationRequired(plain) {
|
||||
t.Fatal("message text alone must not report confirmation_required")
|
||||
}
|
||||
confirmation := NewValidation(
|
||||
"blocked",
|
||||
WithReason("confirmation_required"),
|
||||
)
|
||||
if !IsConfirmationRequired(confirmation) {
|
||||
t.Fatal("typed confirmation error must report confirmation_required")
|
||||
}
|
||||
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
|
||||
wrapped := fmt.Errorf("call tool: %w", confirmation)
|
||||
if !IsConfirmationRequired(wrapped) {
|
||||
t.Fatal("wrapped confirmation error must report confirmation_required")
|
||||
}
|
||||
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
|
||||
if IsConfirmationRequired(otherReason) {
|
||||
t.Fatal("other reasons must not report confirmation_required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:战略解码、经营合约、计分卡、用户目标、周月报。
|
||||
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
@@ -38,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",
|
||||
@@ -138,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",
|
||||
@@ -257,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",
|
||||
@@ -359,11 +360,41 @@ scopeType 支持:
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
scorecardSearchContentCmd := &cobra.Command{
|
||||
Use: "search-entities",
|
||||
Short: "搜索计分卡指标与关键事项",
|
||||
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
|
||||
Example: ` dws agoal scorecard search-entities --keyword "业绩"
|
||||
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"keyword": mustGetFlag(cmd, "keyword"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
return callMCPTool("search_score_card_entities", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
|
||||
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
|
||||
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
|
||||
|
||||
userRulesCmd := &cobra.Command{
|
||||
Use: "rules",
|
||||
@@ -414,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",
|
||||
@@ -489,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",
|
||||
|
||||
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
|
||||
return groupRunE(cmd, args)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(root)
|
||||
|
||||
// root 和 person 各自定义同一组本地 flag,这样:
|
||||
// - dws aisearch --query xxx ← root 自己能解析
|
||||
|
||||
+436
-23
@@ -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",
|
||||
@@ -8422,6 +8424,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
sectionMoveNodeCmd,
|
||||
)
|
||||
|
||||
// ── datasource: 数据源同步管理 ──────────────────────────────
|
||||
|
||||
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
|
||||
|
||||
datasourceGetConfigCmd := &cobra.Command{
|
||||
Use: "get-config",
|
||||
Short: "获取数据源表同步配置",
|
||||
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_config", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_config",
|
||||
CanonicalPath: "aitable.datasource_get_config",
|
||||
CLIPath: "aitable datasource get-config",
|
||||
PrimaryCLIPath: "aitable datasource get-config",
|
||||
},
|
||||
Description: "获取数据源表的同步配置信息。",
|
||||
Interface: aitableMCPInterface("get_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取数据源表的同步配置信息。",
|
||||
UseWhen: []string{"查看已有数据源表的配置详情时"},
|
||||
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
|
||||
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
|
||||
datasourceListSourcesCmd := &cobra.Command{
|
||||
Use: "list-sources",
|
||||
Short: "列出可用数据源来源",
|
||||
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("list_datasource_sources", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_list_sources",
|
||||
CanonicalPath: "aitable.datasource_list_sources",
|
||||
CLIPath: "aitable datasource list-sources",
|
||||
PrimaryCLIPath: "aitable datasource list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。",
|
||||
Interface: aitableMCPInterface("list_datasource_sources"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
|
||||
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
|
||||
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
|
||||
validateJSONObject := func(flag, raw string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
|
||||
}
|
||||
if _, ok := v.(map[string]any); !ok {
|
||||
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
validateAutoSyncSetting := func(raw string) error {
|
||||
return validateJSONObject("auto-sync-setting", raw)
|
||||
}
|
||||
|
||||
datasourceGetFieldsCmd := &cobra.Command{
|
||||
Use: "get-fields",
|
||||
Short: "获取数据源可同步字段列表",
|
||||
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_fields", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_fields",
|
||||
CanonicalPath: "aitable.datasource_get_fields",
|
||||
CLIPath: "aitable datasource get-fields",
|
||||
PrimaryCLIPath: "aitable datasource get-fields",
|
||||
},
|
||||
Description: "获取指定数据源来源的可同步字段列表。",
|
||||
Interface: aitableMCPInterface("get_datasource_fields"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
|
||||
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
|
||||
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
|
||||
|
||||
datasourceCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建数据源表并触发首次同步",
|
||||
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
"auto": auto,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
return callAitableTool("create_datasource", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_create",
|
||||
CanonicalPath: "aitable.datasource_create",
|
||||
CLIPath: "aitable datasource create",
|
||||
PrimaryCLIPath: "aitable datasource create",
|
||||
},
|
||||
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
|
||||
Interface: aitableMCPInterface("create_datasource"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
|
||||
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
|
||||
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
|
||||
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
|
||||
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
|
||||
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新数据源表同步配置并触发同步",
|
||||
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
}
|
||||
if cmd.Flags().Changed("source-config") {
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
|
||||
}
|
||||
if cmd.Flags().Changed("auto") {
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs["auto"] = auto
|
||||
}
|
||||
if cmd.Flags().Changed("field-ids") {
|
||||
v := mustGetFlag(cmd, "field-ids")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if cmd.Flags().Changed("auto-sync-setting") {
|
||||
v := mustGetFlag(cmd, "auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
|
||||
}
|
||||
return callAitableTool("update_datasource_config", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_update",
|
||||
CanonicalPath: "aitable.datasource_update",
|
||||
CLIPath: "aitable datasource update",
|
||||
PrimaryCLIPath: "aitable datasource update",
|
||||
},
|
||||
Description: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
Interface: aitableMCPInterface("update_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
|
||||
Examples: []string{
|
||||
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
|
||||
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig"},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
|
||||
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
|
||||
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
|
||||
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "触发数据源表手动同步",
|
||||
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
return callAitableTool("run_datasource_sync", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableIds": tableIDs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync",
|
||||
CanonicalPath: "aitable.datasource_sync",
|
||||
CLIPath: "aitable datasource sync",
|
||||
PrimaryCLIPath: "aitable datasource sync",
|
||||
},
|
||||
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
|
||||
Interface: aitableMCPInterface("run_datasource_sync"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
|
||||
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceSyncStatusCmd := &cobra.Command{
|
||||
Use: "sync-status",
|
||||
Short: "按任务 ID 查询数据源同步任务状态",
|
||||
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
|
||||
if len(ids) < 1 || len(ids) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"taskIds": ids,
|
||||
}
|
||||
return callAitableTool("get_datasource_sync_status", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync_status",
|
||||
CanonicalPath: "aitable.datasource_sync_status",
|
||||
CLIPath: "aitable datasource sync-status",
|
||||
PrimaryCLIPath: "aitable datasource sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
|
||||
Interface: aitableMCPInterface("get_datasource_sync_status"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
|
||||
AvoidWhen: []string{"触发同步用 datasource sync"},
|
||||
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceCmd.AddCommand(
|
||||
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
|
||||
datasourceCreateCmd, datasourceUpdateCmd,
|
||||
datasourceSyncCmd, datasourceSyncStatusCmd,
|
||||
)
|
||||
|
||||
// 组装 aitable 命令树
|
||||
root.AddCommand(
|
||||
baseCmd, tableCmd, fieldCmd,
|
||||
@@ -8432,6 +8844,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
attachmentCmd, templateCmd,
|
||||
advpermCmd,
|
||||
sectionCmd,
|
||||
datasourceCmd,
|
||||
)
|
||||
|
||||
// 批量注册 --base 作为 --base-id 的隐藏别名
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type aitableDatasourceCaller struct {
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableDatasourceCaller) Format() string { return "json" }
|
||||
func (*aitableDatasourceCaller) DryRun() bool { return false }
|
||||
func (*aitableDatasourceCaller) Fields() string { return "" }
|
||||
func (*aitableDatasourceCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableDatasourceCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
|
||||
|
||||
root := newAitableCommand()
|
||||
root.SetArgs(append([]string{"datasource"}, args...))
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1")
|
||||
if err != nil {
|
||||
t.Fatalf("single table-id should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "task-ids") {
|
||||
t.Fatalf("error = %v, want task-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-config",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err != nil {
|
||||
t.Fatalf("get-config should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["tableId"] != "TBL456" {
|
||||
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "list-sources",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err != nil {
|
||||
t.Fatalf("list-sources should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
|
||||
t.Fatalf("error = %v, want datasource-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("get-fields should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false when not provided", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("create with --auto should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want at least one config change required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when no changes provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto only should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with source-config should succeed: %v", err)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
if _, ok := caller.calls[0].args["auto"]; ok {
|
||||
t.Fatalf("auto should not be sent when --auto is omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto=false should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: "批量查询当前用户的群会话设置",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command {
|
||||
},
|
||||
}
|
||||
media.AddCommand(newChatMediaUploadCommand())
|
||||
newHybridGroupCommand(media)
|
||||
return media
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
|
||||
if os.Getenv(helpersShellStubEnv) == "1" {
|
||||
os.Exit(runHelpersShellStub())
|
||||
}
|
||||
// Cobra's Windows pre-exec hook walks the process table on every Execute*
|
||||
// call to detect Explorer launches. Helpers tests execute command trees
|
||||
// thousands of times, and none of those in-process invocations can be an
|
||||
// Explorer launch, so keep that production-only check out of the test
|
||||
// process. This also prevents every new exhaustive harness from having to
|
||||
// remember a test-local override.
|
||||
originalMousetrapHelpText := cobra.MousetrapHelpText
|
||||
cobra.MousetrapHelpText = ""
|
||||
code := m.Run()
|
||||
cobra.MousetrapHelpText = originalMousetrapHelpText
|
||||
if helpersShellStubBaseDir != "" {
|
||||
_ = os.RemoveAll(helpersShellStubBaseDir)
|
||||
}
|
||||
|
||||
+17
-17
@@ -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 '") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user