Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ab883f11f0 | ||
|
|
7101ffc89c | ||
|
|
4381a54efa | ||
|
|
4fb3349dcd | ||
|
|
de7591be9c | ||
|
|
0d8008dabf | ||
|
|
ce8fe16f50 | ||
|
|
1ba326279f | ||
|
|
e841d41640 | ||
|
|
7b5e3e2d1f |
@@ -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: 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'
|
||||
|
||||
+461
-4
@@ -5,12 +5,17 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Keep only the latest revision of a pull request: a stale run must not
|
||||
# compete with its replacement for hosted runners. A replacement that sees
|
||||
# a cold baseline cache recomputes it authoritatively. Protected-main pushes
|
||||
# remain keyed by exact SHA so every potential merge base has a producer.
|
||||
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -837,7 +842,9 @@ jobs:
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
@@ -916,6 +923,296 @@ jobs:
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
# Null and non-built-in merge identities emit either the protected-main
|
||||
# push or the trusted pull_request_target closed repair. The built-in
|
||||
# Actions identity is the exceptional unsafe path, so its own token must
|
||||
# prove that main-merge-writers never lets it update main.
|
||||
- name: Verify auto-merge identity
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const eventBaseSha = context.payload.pull_request.base.sha;
|
||||
const configuredAppSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim();
|
||||
const reviewedForkAppSlug = 'dingtalk-dws-reviewer-router';
|
||||
const pullHeadRepository =
|
||||
context.payload.pull_request.head.repo.full_name?.toLowerCase();
|
||||
const baseRepository = `${owner}/${repo}`.toLowerCase();
|
||||
const isForkPull =
|
||||
Boolean(pullHeadRepository) && pullHeadRepository !== baseRepository;
|
||||
const appSlug =
|
||||
configuredAppSlug || (isForkPull ? reviewedForkAppSlug : '');
|
||||
if (
|
||||
!appSlug ||
|
||||
appSlug !== appSlug.toLowerCase() ||
|
||||
appSlug === 'github-actions'
|
||||
) {
|
||||
core.setFailed(
|
||||
'Reviewer Router App slug repository variable is missing or unsafe.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!configuredAppSlug) {
|
||||
core.info(
|
||||
`Fork pull request cannot read the repository App slug variable; using the reviewed public slug ${reviewedForkAppSlug}.`,
|
||||
);
|
||||
}
|
||||
const expectedAppOwner = `${appSlug}[bot]`;
|
||||
const writerRulesetName = 'main-merge-writers';
|
||||
const skipWorkflowPattern =
|
||||
/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i;
|
||||
const {data: repository} = await github.rest.repos.get({owner, repo});
|
||||
function classifyMergeDefaults(repository) {
|
||||
if (
|
||||
repository === null ||
|
||||
typeof repository !== 'object' ||
|
||||
Array.isArray(repository)
|
||||
) {
|
||||
return 'invalid';
|
||||
}
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_title',
|
||||
);
|
||||
const hasMessage = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_message',
|
||||
);
|
||||
if (!hasTitle && !hasMessage) {
|
||||
return 'omitted';
|
||||
}
|
||||
if (!hasTitle || !hasMessage) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (
|
||||
repository.merge_commit_title === 'MERGE_MESSAGE' &&
|
||||
['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)
|
||||
) {
|
||||
return 'reviewed';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
const mergeDefaultsProjection = classifyMergeDefaults(repository);
|
||||
if (mergeDefaultsProjection === 'invalid') {
|
||||
core.setFailed(
|
||||
'Repository merge-message defaults are malformed or changed from their reviewed values.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mergeDefaultsProjection === 'omitted') {
|
||||
core.info(
|
||||
'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.',
|
||||
);
|
||||
}
|
||||
|
||||
const appliedRules = await github.paginate(
|
||||
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
|
||||
{owner, repo, branch: 'main', per_page: 100},
|
||||
);
|
||||
const repositorySource = `${owner}/${repo}`.toLowerCase();
|
||||
const applicableRulesetIDs = [
|
||||
...new Set(
|
||||
appliedRules
|
||||
.filter(rule =>
|
||||
rule.ruleset_source_type === 'Repository' &&
|
||||
rule.ruleset_source?.toLowerCase() === repositorySource &&
|
||||
Number.isSafeInteger(Number(rule.ruleset_id)) &&
|
||||
Number(rule.ruleset_id) > 0,
|
||||
)
|
||||
.map(rule => Number(rule.ruleset_id)),
|
||||
),
|
||||
];
|
||||
const activeMainRulesets = [];
|
||||
for (const rulesetID of applicableRulesetIDs) {
|
||||
const {data: ruleset} = await github.request(
|
||||
'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}',
|
||||
{owner, repo, ruleset_id: rulesetID},
|
||||
);
|
||||
if (
|
||||
ruleset.enforcement !== 'active' ||
|
||||
ruleset.target !== 'branch' ||
|
||||
ruleset.source_type !== 'Repository' ||
|
||||
ruleset.source?.toLowerCase() !== repositorySource
|
||||
) {
|
||||
core.setFailed(
|
||||
`Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
activeMainRulesets.push(ruleset);
|
||||
}
|
||||
|
||||
const writerRulesets = activeMainRulesets.filter(
|
||||
ruleset => ruleset.name === writerRulesetName,
|
||||
);
|
||||
if (writerRulesets.length !== 1) {
|
||||
core.setFailed(
|
||||
`Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const writerRuleset = writerRulesets[0];
|
||||
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
|
||||
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
|
||||
// GitHub's read projection omits the entire parameters property
|
||||
// when this exception is disabled. Accept only that exact omission
|
||||
// or a one-field object containing exact false.
|
||||
function isStrictUpdateRule(rule) {
|
||||
if (rule?.type !== 'update') {
|
||||
return false;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
|
||||
return true;
|
||||
}
|
||||
const parameters = rule.parameters;
|
||||
if (
|
||||
parameters === null ||
|
||||
typeof parameters !== 'object' ||
|
||||
Array.isArray(parameters)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const parameterKeys = Object.keys(parameters);
|
||||
return (
|
||||
parameterKeys.length === 1 &&
|
||||
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
|
||||
parameters.update_allows_fetch_and_merge === false
|
||||
);
|
||||
}
|
||||
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
|
||||
const restRulesetID = Number(restRuleset?.id);
|
||||
const graphRulesetID = Number(graphRuleset?.databaseId);
|
||||
const graphRules = graphRuleset?.rules;
|
||||
const graphRule = graphRules?.nodes?.[0];
|
||||
return (
|
||||
Number.isSafeInteger(restRulesetID) &&
|
||||
restRulesetID > 0 &&
|
||||
graphRulesetID === restRulesetID &&
|
||||
graphRuleset.name === restRuleset.name &&
|
||||
graphRuleset.enforcement === 'ACTIVE' &&
|
||||
graphRuleset.target === 'BRANCH' &&
|
||||
graphRules?.totalCount === 1 &&
|
||||
graphRules.nodes?.length === 1 &&
|
||||
graphRule?.type === 'UPDATE' &&
|
||||
graphRule.parameters?.__typename === 'UpdateParameters' &&
|
||||
graphRule.parameters.updateAllowsFetchAndMerge === false
|
||||
);
|
||||
}
|
||||
if (
|
||||
writerIncludes.length !== 1 ||
|
||||
writerIncludes[0] !== 'refs/heads/main' ||
|
||||
writerExcludes.length !== 0 ||
|
||||
typeof writerRuleset.node_id !== 'string' ||
|
||||
!writerRuleset.node_id ||
|
||||
writerRuleset.rules?.length !== 1 ||
|
||||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
|
||||
writerRuleset.current_user_can_bypass !== 'never'
|
||||
) {
|
||||
core.setFailed(
|
||||
`${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const {node: graphWriterRuleset} = await github.graphql(
|
||||
`query ReviewerRouterWriterRule($rulesetID: ID!) {
|
||||
node(id: $rulesetID) {
|
||||
... on RepositoryRuleset {
|
||||
databaseId
|
||||
name
|
||||
enforcement
|
||||
target
|
||||
rules(first: 2) {
|
||||
totalCount
|
||||
nodes {
|
||||
type
|
||||
parameters {
|
||||
__typename
|
||||
... on UpdateParameters {
|
||||
updateAllowsFetchAndMerge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{rulesetID: writerRuleset.node_id},
|
||||
);
|
||||
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
|
||||
core.setFailed(
|
||||
`${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 6;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.base.sha !== eventBaseSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mergeTexts = [
|
||||
currentPull.title,
|
||||
currentPull.auto_merge?.commit_title,
|
||||
currentPull.auto_merge?.commit_message,
|
||||
].filter(value => typeof value === 'string');
|
||||
if (mergeTexts.some(value => skipWorkflowPattern.test(value))) {
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!currentPull.auto_merge) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase();
|
||||
const safeCommitHeadline = `Merge pull request #${pullNumber}`;
|
||||
const safeCommitBody =
|
||||
`Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`;
|
||||
if (
|
||||
enabledBy === expectedAppOwner &&
|
||||
currentPull.auto_merge.commit_title === safeCommitHeadline &&
|
||||
currentPull.auto_merge.commit_message === safeCommitBody
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
core.info(
|
||||
`PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`,
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`,
|
||||
);
|
||||
}
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
needs: lint
|
||||
@@ -1246,7 +1543,7 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -1381,6 +1678,143 @@ jobs:
|
||||
path: coverage-base.txt
|
||||
retention-days: 1
|
||||
|
||||
# Documentation and release-seal pushes do not change executable coverage,
|
||||
# but their new main SHA is still a future PR merge base. Promote only an
|
||||
# exact predecessor cache after independently proving the whole push changed
|
||||
# metadata paths; fall back to a full authoritative profile on a cold chain.
|
||||
coverage-main-metadata:
|
||||
name: Coverage (main metadata cache)
|
||||
needs: lint
|
||||
if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out exact metadata-only main revision
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go-metadata
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify metadata-only main successor
|
||||
shell: bash
|
||||
env:
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
PUSH_AFTER_SHA: ${{ github.event.after }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
full_commit='^[0-9a-f]{40}$'
|
||||
[[ "$PUSH_BEFORE_SHA" =~ $full_commit ]]
|
||||
[[ "$PUSH_AFTER_SHA" =~ $full_commit ]]
|
||||
test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000
|
||||
test "$PUSH_AFTER_SHA" = "$GITHUB_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null
|
||||
git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
|
||||
changed_count=0
|
||||
while IFS= read -r -d '' path; do
|
||||
changed_count=$((changed_count + 1))
|
||||
case "$path" in
|
||||
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*)
|
||||
;;
|
||||
.changes/*)
|
||||
if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] ||
|
||||
[[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Refusing coverage-cache promotion for executable path: $path" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA")
|
||||
test "$changed_count" -gt 0
|
||||
echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore existing current-SHA coverage profile
|
||||
id: metadata-current-cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
- name: Validate existing current-SHA coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Restore exact predecessor coverage profile
|
||||
id: metadata-source-cache
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
- name: Validate promoted predecessor coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold metadata baseline
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute cold metadata baseline
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save metadata main SHA coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
# actions/cache/save reports upload failures as warnings. Convert an
|
||||
# absent exact target key into a hard producer failure.
|
||||
- name: Verify metadata main SHA coverage cache exists
|
||||
id: metadata-target-cache-verification
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact metadata main SHA coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
@@ -1389,6 +1823,7 @@ jobs:
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-main-metadata
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
@@ -1405,6 +1840,7 @@ jobs:
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
run: |
|
||||
@@ -1413,10 +1849,14 @@ jobs:
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
main_metadata_expected=skipped
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
main_metadata_expected=success
|
||||
fi
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
@@ -1432,7 +1872,8 @@ jobs:
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected" \
|
||||
"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
remainder="${profile#*:}"
|
||||
@@ -1572,6 +2013,22 @@ jobs:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Verify push coverage cache exists
|
||||
id: push-cache-verification
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact push coverage cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
name: Coverage Baseline Promotion
|
||||
|
||||
run-name: Promote coverage baseline for ${{ github.event.client_payload.target_sha }}
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [coverage-baseline-promote]
|
||||
|
||||
# repository_dispatch loads this workflow from the protected default branch.
|
||||
# The requested target is treated as untrusted input until the validation step
|
||||
# proves it is an exact Formula-only successor already contained in main.
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Validate Formula-only main target
|
||||
id: validate-target
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const targetSha = context.payload.client_payload?.target_sha;
|
||||
const sourceRunId = context.payload.client_payload?.source_run_id;
|
||||
const checkRunId = Number(context.payload.client_payload?.check_run_id);
|
||||
if (!/^[0-9a-f]{40}$/.test(targetSha || '')) {
|
||||
throw new Error('coverage-baseline-promote requires one full target_sha');
|
||||
}
|
||||
if (!/^[1-9][0-9]*$/.test(sourceRunId || '')) {
|
||||
throw new Error('coverage-baseline-promote requires one source_run_id');
|
||||
}
|
||||
if (!Number.isSafeInteger(checkRunId) || checkRunId <= 0) {
|
||||
throw new Error('coverage-baseline-promote requires one safe check_run_id');
|
||||
}
|
||||
|
||||
// Bind the finalizer before any target or cache validation. A
|
||||
// later failure must complete the release-created acknowledgement
|
||||
// instead of leaving Release to poll a permanently queued check.
|
||||
const promotionExternalId = `release-${sourceRunId}-${targetSha}`;
|
||||
const {data: promotionCheck} = await github.rest.checks.get({
|
||||
owner,
|
||||
repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
promotionCheck.id !== checkRunId ||
|
||||
promotionCheck.head_sha !== targetSha ||
|
||||
promotionCheck.name !== 'Coverage Baseline Cache' ||
|
||||
promotionCheck.external_id !== promotionExternalId ||
|
||||
promotionCheck.app?.slug !== 'github-actions' ||
|
||||
promotionCheck.status !== 'queued' ||
|
||||
promotionCheck.conclusion !== null
|
||||
) {
|
||||
throw new Error('coverage baseline acknowledgement has an invalid identity');
|
||||
}
|
||||
core.setOutput('target_sha', targetSha);
|
||||
core.setOutput('check_run_id', String(checkRunId));
|
||||
core.setOutput('check_external_id', promotionExternalId);
|
||||
|
||||
const {data: targetCommit} = await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: targetSha,
|
||||
per_page: 100,
|
||||
});
|
||||
const files = targetCommit.files || [];
|
||||
const message = targetCommit.commit.message;
|
||||
const formulaPath = files[0]?.filename;
|
||||
const stableFormula =
|
||||
formulaPath === 'Formula/dingtalk-workspace-cli.rb' &&
|
||||
/^chore: update formula for v[0-9]+\.[0-9]+\.[0-9]+ \[skip ci\]$/.test(message);
|
||||
const betaFormula =
|
||||
formulaPath === 'Formula/dingtalk-workspace-cli-beta.rb' &&
|
||||
/^chore: update beta formula for v[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]* \[skip ci\]$/.test(message);
|
||||
if (
|
||||
targetCommit.sha !== targetSha ||
|
||||
targetCommit.parents.length !== 1 ||
|
||||
targetCommit.author?.login !== 'github-actions[bot]' ||
|
||||
targetCommit.committer?.login !== 'github-actions[bot]' ||
|
||||
files.length !== 1 ||
|
||||
!['added', 'modified'].includes(files[0].status) ||
|
||||
(!stableFormula && !betaFormula)
|
||||
) {
|
||||
throw new Error(
|
||||
`${targetSha} is not an exact release-produced Formula-only commit`,
|
||||
);
|
||||
}
|
||||
|
||||
const parentSha = targetCommit.parents[0].sha;
|
||||
const requiredContexts = [
|
||||
'Lint',
|
||||
'Test',
|
||||
'Coverage',
|
||||
'Policy',
|
||||
'Edition',
|
||||
'Interface Integrity',
|
||||
'AI Behavior',
|
||||
'CLI Smoke',
|
||||
'Mock MCP',
|
||||
];
|
||||
async function requireSuccessfulAdmission(ref, label) {
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
const runs = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
filter: 'latest',
|
||||
per_page: 100,
|
||||
});
|
||||
const latestByName = new Map();
|
||||
for (const run of runs) {
|
||||
if (
|
||||
run.head_sha !== ref ||
|
||||
run.app?.slug !== 'github-actions' ||
|
||||
!requiredContexts.includes(run.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = latestByName.get(run.name);
|
||||
if (!current || run.id > current.id) {
|
||||
latestByName.set(run.name, run);
|
||||
}
|
||||
}
|
||||
const invalid = requiredContexts.filter((name) => {
|
||||
const run = latestByName.get(name);
|
||||
return !run || run.conclusion !== 'success';
|
||||
});
|
||||
if (invalid.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`${label} ${ref} lacks successful Code Admission contexts: ${invalid.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await requireSuccessfulAdmission(parentSha, 'Formula parent');
|
||||
await requireSuccessfulAdmission(targetSha, 'Formula target');
|
||||
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: context.payload.repository.default_branch,
|
||||
});
|
||||
const {data: containment} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
if (!['ahead', 'identical'].includes(containment.status)) {
|
||||
throw new Error(`${targetSha} is not contained in the protected default branch`);
|
||||
}
|
||||
|
||||
core.setOutput('parent_sha', parentSha);
|
||||
core.setOutput('formula_path', formulaPath);
|
||||
|
||||
- name: Mark Formula cache promotion in progress
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: Number('${{ steps.validate-target.outputs.check_run_id }}'),
|
||||
status: 'in_progress',
|
||||
started_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: 'Producing exact-SHA coverage baseline',
|
||||
summary: 'The trusted default-branch workflow is validating or producing the main-scoped cache.',
|
||||
},
|
||||
});
|
||||
|
||||
- name: Check out validated Formula-only target
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ steps.validate-target.outputs.target_sha }}
|
||||
|
||||
- name: Verify checked-out Formula-only identity
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.validate-target.outputs.target_sha }}
|
||||
PARENT_SHA: ${{ steps.validate-target.outputs.parent_sha }}
|
||||
FORMULA_PATH: ${{ steps.validate-target.outputs.formula_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
test "$(git rev-parse HEAD^)" = "$PARENT_SHA"
|
||||
test "$(git diff --name-only --no-renames "$PARENT_SHA" "$TARGET_SHA")" = "$FORMULA_PATH"
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore existing target coverage profile
|
||||
id: target-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate existing target coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Restore exact Formula parent coverage profile
|
||||
id: parent-cache
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate promoted Formula parent profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold Formula baseline
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute cold Formula baseline
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save Formula main SHA coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Verify Formula main SHA coverage cache exists
|
||||
id: formula-target-cache-verification
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact Formula main SHA coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
- name: Complete Formula cache promotion acknowledgement
|
||||
if: ${{ always() && steps.validate-target.outputs.check_run_id != '' }}
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
PROMOTION_JOB_STATUS: ${{ job.status }}
|
||||
with:
|
||||
script: |
|
||||
const checkRunId = Number('${{ steps.validate-target.outputs.check_run_id }}');
|
||||
const targetSha = '${{ steps.validate-target.outputs.target_sha }}';
|
||||
const expectedExternalId = '${{ steps.validate-target.outputs.check_external_id }}';
|
||||
const {data: currentCheck} = await github.rest.checks.get({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
currentCheck.head_sha !== targetSha ||
|
||||
currentCheck.name !== 'Coverage Baseline Cache' ||
|
||||
currentCheck.external_id !== expectedExternalId ||
|
||||
currentCheck.app?.slug !== 'github-actions'
|
||||
) {
|
||||
throw new Error('refusing to update a changed promotion acknowledgement');
|
||||
}
|
||||
const succeeded = process.env.PROMOTION_JOB_STATUS === 'success';
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
status: 'completed',
|
||||
conclusion: succeeded ? 'success' : 'failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: succeeded
|
||||
? 'Exact-SHA coverage baseline is available'
|
||||
: 'Exact-SHA coverage baseline promotion failed',
|
||||
summary: succeeded
|
||||
? `Verified the main-scoped exact cache for ${targetSha}.`
|
||||
: `Promotion failed for ${targetSha}; rerun the failed Release job after correcting the producer.`,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
name: Coverage Baseline Repair
|
||||
|
||||
run-name: Repair coverage baseline from ${{ github.event_name }}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [closed]
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
repository_dispatch:
|
||||
types: [coverage-baseline-repair]
|
||||
schedule:
|
||||
- cron: "23 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# pull_request_target and workflow_run are allowed to inspect only GitHub API
|
||||
# data and dispatch the trusted producer. GitHub deliberately makes both
|
||||
# triggers read-only for the default-branch cache, so all checkout and cache
|
||||
# writes live in repository_dispatch, schedule, or main-only workflow_dispatch.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
# Retain every pending repair for one target. actionlint v1.7.12's bundled
|
||||
# schema predates GitHub's concurrency.queue support.
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
dispatch-merged-pr:
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
# Never check out or execute pull-request content in this privileged
|
||||
# base-owned event. Re-read the merged PR, bind every immutable identity,
|
||||
# prove the result is in main, and send only those values to the producer.
|
||||
- name: Dispatch trusted merged-PR repair
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const eventPull = context.payload.pull_request;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
const pullNumber = Number(eventPull?.number);
|
||||
const headSha = eventPull?.head?.sha;
|
||||
const baseRef = eventPull?.base?.ref;
|
||||
const mergeCommitSha = eventPull?.merge_commit_sha;
|
||||
if (
|
||||
context.payload.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
context.payload.repository?.default_branch !== 'main' ||
|
||||
!Number.isSafeInteger(pullNumber) ||
|
||||
pullNumber <= 0 ||
|
||||
!fullCommit.test(headSha || '') ||
|
||||
baseRef !== 'main' ||
|
||||
!fullCommit.test(mergeCommitSha || '')
|
||||
) {
|
||||
throw new Error('closed PR event has an invalid repository or revision identity');
|
||||
}
|
||||
|
||||
// REST base.sha follows the live base branch and can move after
|
||||
// merge. Bind the closed event's stable PR head snapshot and merge
|
||||
// facts, then authorize the target through main containment.
|
||||
function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}
|
||||
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (!isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
)) {
|
||||
throw new Error(`PR #${pullNumber} no longer matches the merged-main event`);
|
||||
}
|
||||
|
||||
async function requireMainContainment(targetSha) {
|
||||
let lastState = 'not checked';
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
try {
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const {data: comparison} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
lastState = comparison.status;
|
||||
if (['ahead', 'identical'].includes(comparison.status)) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastState = error.message;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${targetSha} is not contained in protected main after retries: ${lastState}`,
|
||||
);
|
||||
}
|
||||
await requireMainContainment(mergeCommitSha);
|
||||
|
||||
// Normal App or human merges emit a protected-main push run whose
|
||||
// CI producer owns this exact key. Give Actions event delivery a
|
||||
// short visibility window and avoid a duplicate full-suite repair.
|
||||
// A workflow-skip directive or suppressed built-in-token event has
|
||||
// no such run, so only that missing-event path reaches dispatch.
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
ciWorkflow.state !== 'active'
|
||||
) {
|
||||
throw new Error('protected CI workflow identity is not active or exact');
|
||||
}
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
const {data: workflowRuns} =
|
||||
await github.rest.actions.listWorkflowRunsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
event: 'push',
|
||||
per_page: 100,
|
||||
});
|
||||
const exactPushRun = workflowRuns.workflow_runs.find(run =>
|
||||
run.name === 'CI' &&
|
||||
run.workflow_id === ciWorkflow.id &&
|
||||
run.path === ciWorkflow.path &&
|
||||
run.event === 'push' &&
|
||||
run.head_sha === mergeCommitSha &&
|
||||
run.head_branch === 'main' &&
|
||||
['queued', 'in_progress', 'completed'].includes(run.status),
|
||||
);
|
||||
if (exactPushRun) {
|
||||
core.info(
|
||||
`CI push run ${exactPushRun.id} already owns the exact-SHA producer for ${mergeCommitSha}; repair dispatch is unnecessary.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < 12) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
// repository_dispatch is one of GitHub's explicit GITHUB_TOKEN
|
||||
// recursion exceptions and receives default-branch cache-write scope.
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'coverage-baseline-repair',
|
||||
client_payload: {
|
||||
source: 'merged_pr',
|
||||
pull_number: String(pullNumber),
|
||||
head_sha: headSha,
|
||||
merge_commit_sha: mergeCommitSha,
|
||||
source_run_id: String(context.runId),
|
||||
},
|
||||
});
|
||||
core.info(
|
||||
`Dispatched exact-SHA coverage repair for merged PR #${pullNumber} at ${mergeCommitSha}.`,
|
||||
);
|
||||
|
||||
dispatch-failed-ci:
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' &&
|
||||
github.event.workflow_run.name == 'CI' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.status == 'completed' &&
|
||||
github.event.workflow_run.conclusion != 'success'
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
# workflow_run cannot write the default-branch cache. Re-read the exact
|
||||
# completed CI run from Actions, bind it to the protected CI workflow and
|
||||
# main revision, then use the repository_dispatch recursion exception.
|
||||
- name: Dispatch trusted failed-CI repair
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const upstream = 'DingTalk-Real-AI/dingtalk-workspace-cli';
|
||||
const eventRun = context.payload.workflow_run;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
const runID = Number(eventRun?.id);
|
||||
const runAttempt = Number(eventRun?.run_attempt);
|
||||
const headSha = eventRun?.head_sha;
|
||||
const conclusion = eventRun?.conclusion;
|
||||
if (
|
||||
context.payload.repository?.full_name !== upstream ||
|
||||
context.payload.repository?.default_branch !== 'main' ||
|
||||
!Number.isSafeInteger(runID) ||
|
||||
runID <= 0 ||
|
||||
!Number.isSafeInteger(runAttempt) ||
|
||||
runAttempt <= 0 ||
|
||||
eventRun?.name !== 'CI' ||
|
||||
eventRun?.event !== 'push' ||
|
||||
eventRun?.head_branch !== 'main' ||
|
||||
eventRun?.status !== 'completed' ||
|
||||
typeof conclusion !== 'string' ||
|
||||
conclusion.length === 0 ||
|
||||
conclusion === 'success' ||
|
||||
!fullCommit.test(headSha || '')
|
||||
) {
|
||||
throw new Error('workflow_run event is not one completed non-success main CI push');
|
||||
}
|
||||
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runID,
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
eventRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.id !== runID ||
|
||||
currentRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.name !== 'CI' ||
|
||||
currentRun.event !== 'push' ||
|
||||
currentRun.head_branch !== 'main' ||
|
||||
currentRun.head_sha !== headSha ||
|
||||
currentRun.run_attempt !== runAttempt ||
|
||||
currentRun.status !== 'completed' ||
|
||||
currentRun.conclusion !== conclusion ||
|
||||
currentRun.conclusion === 'success' ||
|
||||
currentRun.repository?.full_name !== upstream ||
|
||||
currentRun.head_repository?.full_name !== upstream
|
||||
) {
|
||||
throw new Error(`CI workflow run ${runID} no longer matches the completed event`);
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'coverage-baseline-repair',
|
||||
client_payload: {
|
||||
source: 'failed_ci',
|
||||
workflow_run_id: String(runID),
|
||||
workflow_run_attempt: String(runAttempt),
|
||||
workflow_conclusion: conclusion,
|
||||
merge_commit_sha: headSha,
|
||||
source_run_id: String(context.runId),
|
||||
},
|
||||
});
|
||||
core.info(
|
||||
`Dispatched exact-SHA coverage repair for ${conclusion} CI run ${runID} at ${headSha}.`,
|
||||
);
|
||||
|
||||
repair:
|
||||
if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Resolve trusted main repair target
|
||||
id: resolve-target
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
if (
|
||||
context.payload.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
context.payload.repository?.default_branch !== 'main'
|
||||
) {
|
||||
throw new Error('coverage repair is restricted to the protected upstream repository');
|
||||
}
|
||||
|
||||
async function requireMainContainment(targetSha) {
|
||||
let lastState = 'not checked';
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
try {
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const {data: comparison} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
lastState = comparison.status;
|
||||
if (['ahead', 'identical'].includes(comparison.status)) {
|
||||
return branch.commit.sha;
|
||||
}
|
||||
} catch (error) {
|
||||
lastState = error.message;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${targetSha} is not contained in protected main after retries: ${lastState}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The dispatcher froze the stable PR head snapshot in this payload.
|
||||
// Do not re-read mutable base.sha; bind the head and stable merge
|
||||
// facts, then prove protected-main containment below.
|
||||
function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}
|
||||
|
||||
let targetSha;
|
||||
if (context.eventName === 'repository_dispatch') {
|
||||
const payload = context.payload.client_payload || {};
|
||||
const sourceRunIDText = String(payload.source_run_id || '');
|
||||
if (!/^[1-9][0-9]*$/.test(sourceRunIDText)) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid source run');
|
||||
}
|
||||
if (payload.source === 'merged_pr') {
|
||||
const rawPullNumber = String(payload.pull_number || '');
|
||||
const pullNumber = Number(rawPullNumber);
|
||||
const headSha = payload.head_sha;
|
||||
targetSha = payload.merge_commit_sha;
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(rawPullNumber) ||
|
||||
!Number.isSafeInteger(pullNumber) ||
|
||||
!fullCommit.test(headSha || '') ||
|
||||
!fullCommit.test(targetSha || '')
|
||||
) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid PR identity');
|
||||
}
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (!isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
targetSha,
|
||||
)) {
|
||||
throw new Error(
|
||||
`repair payload no longer matches merged PR #${pullNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (payload.source === 'failed_ci') {
|
||||
const rawWorkflowRunID = String(payload.workflow_run_id || '');
|
||||
const workflowRunID = Number(rawWorkflowRunID);
|
||||
const rawWorkflowRunAttempt = String(payload.workflow_run_attempt || '');
|
||||
const workflowRunAttempt = Number(rawWorkflowRunAttempt);
|
||||
const workflowConclusion = payload.workflow_conclusion;
|
||||
targetSha = payload.merge_commit_sha;
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(rawWorkflowRunID) ||
|
||||
!Number.isSafeInteger(workflowRunID) ||
|
||||
!/^[1-9][0-9]*$/.test(rawWorkflowRunAttempt) ||
|
||||
!Number.isSafeInteger(workflowRunAttempt) ||
|
||||
typeof workflowConclusion !== 'string' ||
|
||||
workflowConclusion.length === 0 ||
|
||||
workflowConclusion === 'success' ||
|
||||
!fullCommit.test(targetSha || '')
|
||||
) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid CI identity');
|
||||
}
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: workflowRunID,
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
currentRun.id !== workflowRunID ||
|
||||
currentRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.name !== 'CI' ||
|
||||
currentRun.event !== 'push' ||
|
||||
currentRun.head_branch !== 'main' ||
|
||||
currentRun.head_sha !== targetSha ||
|
||||
currentRun.run_attempt !== workflowRunAttempt ||
|
||||
currentRun.status !== 'completed' ||
|
||||
currentRun.conclusion !== workflowConclusion ||
|
||||
currentRun.conclusion === 'success' ||
|
||||
currentRun.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
currentRun.head_repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli'
|
||||
) {
|
||||
throw new Error(
|
||||
`repair payload no longer matches failed CI run ${workflowRunID}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error('coverage-baseline-repair payload has an unknown source');
|
||||
}
|
||||
await requireMainContainment(targetSha);
|
||||
} else {
|
||||
if (context.ref !== 'refs/heads/main') {
|
||||
throw new Error('scheduled and manual repair must run from refs/heads/main');
|
||||
}
|
||||
// github.sha is the default-branch tip that keyed this workflow's
|
||||
// concurrency group. Keep the producer bound to that exact
|
||||
// event-time target even if main advances while this run queues.
|
||||
targetSha = context.sha;
|
||||
if (!fullCommit.test(targetSha || '')) {
|
||||
throw new Error('protected main did not resolve to one full commit SHA');
|
||||
}
|
||||
await requireMainContainment(targetSha);
|
||||
}
|
||||
core.setOutput('target_sha', targetSha);
|
||||
core.info(`Resolved protected-main coverage repair target ${targetSha}.`);
|
||||
|
||||
- name: Check out exact protected-main target
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ steps.resolve-target.outputs.target_sha }}
|
||||
|
||||
- name: Verify checked-out repair target
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.resolve-target.outputs.target_sha }}
|
||||
run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore exact target coverage profile
|
||||
id: target-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate existing exact target profile
|
||||
if: steps.target-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold repair
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute complete target coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save exact protected-main coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
# Cache uploads are fail-open warnings. A lookup-only restore plus the
|
||||
# explicit cache-hit assertion makes an absent or partial key fail hard.
|
||||
- name: Verify exact protected-main coverage cache exists
|
||||
id: target-cache-verification
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact protected-main coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
@@ -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
+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),
|
||||
|
||||
+187
-10
@@ -147,13 +147,112 @@ maintainer pool. A current-head approval or change request is preserved; after
|
||||
a new push, stale activity does not suppress a fresh request, and an
|
||||
outstanding change requester is preferred for continuity.
|
||||
|
||||
The branch ruleset keeps one human approval and all nine strict required
|
||||
contexts, and requires someone other than the latest pusher to approve after
|
||||
the most recent head update. Repository auto-merge is enabled for ready PRs,
|
||||
so a PR merges after that approval and the current revision's nine checks are
|
||||
green. If `main` advances, strict checks rerun before merge. The reviewer
|
||||
router is orchestration, not a quality context, and must not be added to the
|
||||
ruleset.
|
||||
The branch rulesets keep one human approval and all nine strict required
|
||||
contexts, require someone other than the latest pusher to approve after the
|
||||
most recent head update, and restrict `main` updates to the dedicated Reviewer
|
||||
Router App in pull-request mode plus the designated Formula publishers and
|
||||
break-glass identities. Repository auto-merge is enabled for ready PRs, so the
|
||||
App-owned request records the automation intent while the App's synchronous
|
||||
merge path waits for that approval and the current revision's nine green
|
||||
checks. If `main` advances, strict checks rerun before merge. The
|
||||
reviewer routing job uses the built-in `GITHUB_TOKEN` to request reviewers with
|
||||
`Contents: read` and `Pull requests: write`. A separate base-owned cleanup job
|
||||
isolates the merge-authority permissions (`Contents: write` and `Pull
|
||||
requests: write`), revalidates the exact event base/head, and uses the built-in
|
||||
token only to disable an existing request owned by `github-actions[bot]` or one
|
||||
whose title or merge metadata requests that GitHub skip workflows; it never
|
||||
enables auto-merge. The job then mints a current-repository installation token
|
||||
for the dedicated Reviewer Router GitHub App, proves its emitted slug matches
|
||||
the reviewed `REVIEWER_ROUTER_APP_SLUG`, replaces every non-App request, and
|
||||
enables native auto-merge with fixed metadata. This
|
||||
identity boundary is required because GitHub suppresses
|
||||
most workflow events created by the built-in token; using it for auto-merge would
|
||||
silently skip the merge commit's protected-main CI and baseline-cache
|
||||
producer. Token minting or takeover fails closed without falling back to
|
||||
`GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and
|
||||
the required `Test` context live-verifies the exact `main-merge-writers` update
|
||||
rule. GitHub's read APIs may omit `parameters` for the strict
|
||||
`update_allows_fetch_and_merge: false` value, so the gate accepts only that
|
||||
exact omission or a one-field `parameters` object containing explicit boolean
|
||||
`false`; every other present shape or value fails closed. The gate then binds
|
||||
the same ruleset node through GraphQL and requires its non-null
|
||||
`updateAllowsFetchAndMerge` value to be exactly `false`. It also requires its
|
||||
own built-in token to report
|
||||
`current_user_can_bypass: never`. The minted App separately requires
|
||||
`pull_requests_only` on that writer rule and `never` on every other active main
|
||||
ruleset before it can enable, reconcile, or synchronously merge. The read-only
|
||||
`Test`
|
||||
token may receive a repository projection with both merge-default properties
|
||||
omitted; it accepts only that complete omission or exact `MERGE_MESSAGE` plus
|
||||
`PR_TITLE`/`BLANK`, while partial or malformed projections fail closed.
|
||||
The same unprivileged `pull_request` job may receive an empty repository-variable
|
||||
projection for an external fork. Only when the event head repository differs
|
||||
from the base repository does it substitute the exact reviewed public slug
|
||||
`dingtalk-dws-reviewer-router` for identity comparison. An empty variable on a
|
||||
same-repository PR and every malformed non-empty value still fail closed. This
|
||||
fallback neither mints a token nor grants merge authority; the base-owned
|
||||
Router continues to require its minted App slug to equal the repository
|
||||
variable before any mutation. The minted App's `Contents: write` token must
|
||||
observe the exact reviewed defaults
|
||||
before either mutation path proceeds. GitHub hides the complete
|
||||
`bypass_actors` list from low-privilege callers, so the rollout audit must still
|
||||
keep the writer list at exactly the Reviewer App, `haofeng0705` (ID
|
||||
`30925823`), and `PeterGuy326` (ID `47820304`). The required check finally
|
||||
accepts a null or exact App-owned
|
||||
request after a short takeover grace period. Null is safe from the suppressed
|
||||
event path because the built-in Actions identity cannot update `main`; other
|
||||
permitted identities produce either a main push or the trusted closed-PR
|
||||
repair. Drafts skip the identity step, while `ready_for_review`, `edited`,
|
||||
`auto_merge_enabled`, and `auto_merge_disabled` explicitly start fresh admission
|
||||
for readiness, title, and merge-request changes. Router does not react to
|
||||
`auto_merge_disabled`, so a
|
||||
human can deliberately leave the PR manual-only for break-glass handling.
|
||||
Reviewer routing remains available. The protected-main push that deploys the
|
||||
workflow automatically migrates every open, ready non-App request and repairs
|
||||
unsafe App metadata; it disables workflow-skipping requests for correction.
|
||||
Because GitHub's deferred native auto-merge path does not reliably apply an
|
||||
App's pull-request-only ruleset bypass, a zero-permission approval-signal
|
||||
workflow and completed `CI` / `Code Admission — AI Behavior` workflows wake the
|
||||
same trusted default-branch reconciliation through `workflow_run`. That event
|
||||
is only a wake-up signal: the privileged job does not consume its pull-request
|
||||
payload or artifacts and does not check out the triggering run's code. It
|
||||
re-enumerates open `main` PRs through the API and attempts only an exact
|
||||
App-owned request through the synchronous PR merge endpoint. Immediately before
|
||||
each attempt it revalidates the App's ruleset boundary and PR intent, supplies
|
||||
the current head SHA, and treats server-declared not-ready or
|
||||
concurrent-revision responses as retriable. The live preflight requires the
|
||||
exact repository-owned approval ruleset and exact nine-check strict quality
|
||||
ruleset, with every context bound to the GitHub Actions App
|
||||
(`integration_id=15368`) and the Reviewer Router App unable to bypass either;
|
||||
a missing, disabled, incorrectly sourced, or weakened gate fails closed before
|
||||
merge. GitHub—not the workflow—decides whether the
|
||||
merge is admissible. A staggered twice-hourly schedule provides eventual
|
||||
recovery, and a manual `workflow_dispatch` from `main` is the immediate
|
||||
idempotent retry path.
|
||||
Reconciliation never enables an originally null request. The reviewer router
|
||||
is orchestration, not a quality context, and
|
||||
must not be added to the ruleset.
|
||||
|
||||
Disabling the App-owned request before the reconcile job's final PR read keeps
|
||||
the PR manual-only. GitHub can atomically bind the subsequent merge to the head
|
||||
SHA, but it cannot bind that call to the auto-merge intent; a disable racing
|
||||
after the final read may therefore lose to the in-flight merge. Closing the PR
|
||||
or changing its head blocks the attempt only if GitHub observes that state
|
||||
before accepting the merge endpoint call; no client-side action can revoke a
|
||||
merge that the server has already accepted.
|
||||
|
||||
The merge endpoint has no expected-base precondition. Reconciliation checks
|
||||
that the base is this repository's `main` immediately before and after the call,
|
||||
but a retarget racing after the final read is not atomically preventable in the
|
||||
workflow. Operators must disable the App-owned intent and wait for all running
|
||||
`Reviewer routing` reconciliation jobs to finish before retargeting a PR; a
|
||||
stronger adversarial guarantee requires a GitHub-side branch/ruleset control.
|
||||
|
||||
GitHub may omit `pull_request_target` for security-sensitive head branch names,
|
||||
including names that look like commit SHAs. Those PRs cannot use Router App
|
||||
takeover or the closed-event repair: rename the branch for the supported path,
|
||||
or use the designated break-glass identity with a safe final message so main
|
||||
push CI remains the exact-SHA producer.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
@@ -213,7 +312,76 @@ the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
against the wrong baseline. PR concurrency is keyed by PR number, so a later
|
||||
revision cancels the stale run instead of letting obsolete test matrices
|
||||
compete with the replacement for hosted runners. If cancellation interrupts a
|
||||
cold-cache fallback, the latest run recomputes the same exact merge-base
|
||||
profile authoritatively. Main concurrency remains keyed by pushed SHA, so a
|
||||
newer main push cannot cancel a predecessor's producer.
|
||||
|
||||
Every supported main advancement path has an exact-SHA producer. The required
|
||||
`Test` context rejects GitHub workflow-skip directives in PR and auto-merge
|
||||
metadata, reruns when that metadata is enabled, disabled, or edited, and
|
||||
verifies the live App/writer-ruleset identity contract. Reviewer Router
|
||||
additionally binds auto-merge to the exact head OID and writes a fixed safe
|
||||
merge headline/body. The sole break-glass publisher must retain a safe final
|
||||
message; the release-controlled Formula-only path
|
||||
is the sole supported use of `[skip ci]`. A full source push
|
||||
saves the assembled profile after the aggregate gate passes. A trusted
|
||||
documentation or release-seal push independently verifies that the complete
|
||||
`before...after` diff contains only the reviewed metadata allowlist, restores
|
||||
only the exact `before` cache, recomputes the full profile if the chain is
|
||||
cold, and makes that helper a dependency of the required `Coverage` context.
|
||||
Release-generated Formula commits intentionally retain `[skip ci]`; after
|
||||
their nine synthetic contexts are sealed, an independent release-governance
|
||||
job creates an acknowledgement and emits a `coverage-baseline-promote`
|
||||
repository dispatch. The default-branch promotion
|
||||
workflow revalidates the exact single-parent Formula identity, successful
|
||||
parent and target contexts, and main containment before it promotes the exact
|
||||
parent cache or performs the same full fallback. Every target-main producer
|
||||
follows its save with a lookup-only restore and requires
|
||||
`cache-hit=true` for the exact key; this turns the cache action's otherwise
|
||||
warning-only upload failure or prefix match into a hard failure. Formula
|
||||
promotion additionally updates one release-created `Coverage Baseline Cache`
|
||||
check. A separate confirmation job waits for that exact check-run ID while npm
|
||||
and mirrors remain dependent only on the immutable publication job; cache
|
||||
failure therefore makes the final delivery gate red without creating a
|
||||
partially published release. Once Formula sealing exposes its SHA, a later
|
||||
publication verification failure cannot suppress that confirmation job.
|
||||
|
||||
A separate base-owned `pull_request_target: closed` safety net covers the final
|
||||
merged SHA even if a human or integration changes the merge message after PR
|
||||
checks finish. Skip directives alone do not suppress `pull_request_target`,
|
||||
subject to GitHub's separate security-sensitive branch-name restriction above.
|
||||
That job executes no PR code and only dispatches after binding the exact
|
||||
closed-event PR number and stable head SHA to merged-PR facts
|
||||
(`merged_at`, `base.ref`, and `merge_commit_sha`) and proving `main`
|
||||
containment. It does not compare the later REST `base.sha`, which follows the
|
||||
live base branch after merge. Because GitHub makes default-branch caches
|
||||
read-only to `pull_request_target`, the dispatcher first waits up to one minute
|
||||
for a run from the exact protected
|
||||
`.github/workflows/ci.yml` workflow and exits when that normal producer exists.
|
||||
A successful main CI hard-verifies the exact key itself. A completed
|
||||
non-success run starts a separate base-owned `workflow_run` dispatcher, which
|
||||
binds the exact CI workflow ID/path, run ID/attempt, conclusion, upstream
|
||||
repository, `main` branch, and head SHA. That trigger is also cache-read-only,
|
||||
so either trusted dispatcher uses `repository_dispatch`; its producer
|
||||
revalidates the merged-PR or failed-CI identity, checks out the contained SHA,
|
||||
and produces/verifies the exact full cache.
|
||||
An hourly schedule and a main-only manual dispatch repair the event-time main
|
||||
SHA after a direct break-glass push or cache eviction. The dispatch exception
|
||||
is intentional: unlike an ordinary event created by `GITHUB_TOKEN`, GitHub
|
||||
allows `repository_dispatch` to start another workflow. A legacy built-in-token
|
||||
merge can suppress the closed event too, which is why the required `Test`
|
||||
identity gate and dedicated Reviewer Router App are still mandatory.
|
||||
|
||||
A cold miss can still occur during a producer race or after cache eviction,
|
||||
but it remains fail-safe: the PR recomputes the authoritative baseline with a
|
||||
30-minute job budget and saves a PR-scoped copy for same-PR reruns. It is no
|
||||
longer possible for a supported main-advance path to omit its producer
|
||||
silently. That PR-scoped fallback save remains a best-effort acceleration and
|
||||
does not replace the normal push, metadata, Formula, and merged-PR repair
|
||||
producers. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
@@ -246,7 +414,9 @@ tool、parameter、mapping、positional execution、constraint 与 safety 语义
|
||||
|
||||
The `main` quality ruleset must enable strict required-status-check policy
|
||||
(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever
|
||||
`main` advances. It must require these exact contexts and no legacy aliases:
|
||||
`main` advances. Every entry must select the GitHub Actions App
|
||||
(`integration_id=15368`), not “any source”. It must require these exact
|
||||
context/source pairs and no legacy aliases:
|
||||
|
||||
- `Lint`
|
||||
- `Test`
|
||||
@@ -265,4 +435,11 @@ unproducible required context.
|
||||
|
||||
The branch ruleset also requires one approval after the latest push. Enable
|
||||
repository auto-merge and automatic head-branch deletion; keep the base-owned
|
||||
reviewer router outside the required-context list.
|
||||
reviewer router outside the required-context list. Install its dedicated
|
||||
GitHub App only on this repository with `Contents: read and write` and `Pull
|
||||
requests: read and write`; do not grant Actions, Workflows, or Administration.
|
||||
Give it pull-request-only bypass on `main-merge-writers` and no bypass on any
|
||||
other ruleset. Store the App client ID and lowercase slug in repository
|
||||
variables `REVIEWER_ROUTER_APP_CLIENT_ID` and `REVIEWER_ROUTER_APP_SLUG`, and
|
||||
its private key in repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY`. Do not
|
||||
reuse release, Homebrew, or personal tokens for this boundary.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -237,11 +237,25 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"agoal contract detail": {"agoal", "contract", "detail", "--contract-id", "contract-1"},
|
||||
"agoal contract update": {"agoal", "contract", "update", "--contract-id", "contract-1", "--dimensions", `[{"id":"dimension-1","title":"Fixture Dimension","weight":100,"objectives":[]}]`},
|
||||
"agoal obj-template create-or-update": {"agoal", "obj-template", "create-or-update", "--template-id", "template-1", "--dimensions", `[{"title":"Fixture Dimension","weight":100}]`},
|
||||
"agoal obj-template list": {"agoal", "obj-template", "list", "--keyword", "fixture", "--page", "2", "--page-size", "7"},
|
||||
"agoal report list-statistics": {"agoal", "report", "list-statistics", "--keyword", "Fixture Rule"},
|
||||
"agoal report submit-detail": {"agoal", "report", "submit-detail", "--template-id", "template-1", "--submit-state", "ON_TIME", "--query-date", "2026-06-18T00:00:00+08:00"},
|
||||
"agoal scorecard detail": {"agoal", "scorecard", "detail", "--dept-id", "dept-1", "--selected-time", "2026-01-01T00:00:00+08:00"},
|
||||
"agoal scorecard entity-detail": {"agoal", "scorecard", "entity-detail", "--sc-id", "scorecard-1", "--entity-id", "entity-1"},
|
||||
"agoal strategy detail": {"agoal", "strategy", "detail", "--profile-id", "profile-1"},
|
||||
"agoal user objectives": {"agoal", "user", "objectives", "--user-id", "user-1", "--rule-id", "rule-1", "--period-ids", "period-1,period-2"},
|
||||
"agoal user rules": {"agoal", "user", "rules", "--user-id", "user-1"},
|
||||
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
|
||||
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
|
||||
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
|
||||
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
|
||||
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
|
||||
"audit export": {"audit", "export", "--since", "2026-03-01", "--until", "2026-03-10", "--format", "jsonl", "--output", "/tmp/dws-audit-export-fixture.jsonl"},
|
||||
"audit tail": {"audit", "tail", "--lines", "7", "--output", "/tmp/dws-audit-tail-fixture.jsonl"},
|
||||
"audit verify": {"audit", "verify", "--file", "../../go.mod", "--output", "/tmp/dws-audit-verify-fixture.json"},
|
||||
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
|
||||
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
|
||||
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
|
||||
@@ -267,7 +281,66 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
|
||||
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
|
||||
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
|
||||
"dev app create": {"dev", "app", "create", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"dev app credentials get": {"dev", "app", "credentials", "get", "--unified-app-id", "app-1"},
|
||||
"dev app delete": {"dev", "app", "delete", "--unified-app-id", "app-1", "--confirm-name", "Fixture App", "--yes"},
|
||||
"dev app disable": {"dev", "app", "disable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app enable": {"dev", "app", "enable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app event list": {"dev", "app", "event", "list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"dev app event subscribe": {"dev", "app", "event", "subscribe", "--unified-app-id", "app-1", "--event-codes", "chat_message_received", "--yes"},
|
||||
"dev app event unsubscribe": {"dev", "app", "event", "unsubscribe", "--unified-app-id", "app-1", "--event-codes", "chat_message_received", "--yes"},
|
||||
"dev app list": {"dev", "app", "list", "--robot-name", "Fixture Robot"},
|
||||
"dev app member add": {"dev", "app", "member", "add", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"dev app member list": {"dev", "app", "member", "list", "--unified-app-id", "app-1"},
|
||||
"dev app member remove": {"dev", "app", "member", "remove", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"dev app permission add": {"dev", "app", "permission", "add", "--unified-app-id", "app-1", "--scope-values", "Contact.User.Read", "--yes"},
|
||||
"dev app permission remove": {"dev", "app", "permission", "remove", "--unified-app-id", "app-1", "--scope-values", "Contact.User.Read", "--yes"},
|
||||
"dev app robot config": {"dev", "app", "robot", "config", "--unified-app-id", "app-1", "--i18n-description", `{"zh_CN":"Fixture Robot"}`, "--yes"},
|
||||
"dev app robot disable": {"dev", "app", "robot", "disable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app robot enable": {"dev", "app", "robot", "enable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app robot get": {"dev", "app", "robot", "get", "--unified-app-id", "app-1"},
|
||||
"dev app robot result": {"dev", "app", "robot", "result", "--task-id", "task-1"},
|
||||
"dev app robot submit": {"dev", "app", "robot", "submit", "--name", "Fixture Agent", "--desc", "Fixture robot description", "--robot-name", "Fixture Robot", "--yes"},
|
||||
"dev app security config": {"dev", "app", "security", "config", "--unified-app-id", "app-1", "--redirect-urls", "https://example.test/callback", "--yes"},
|
||||
"dev app update": {"dev", "app", "update", "--unified-app-id", "app-1", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"dev app version check-approval": {"dev", "app", "version", "check-approval", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"dev app version create": {"dev", "app", "version", "create", "--unified-app-id", "app-1", "--version", "1.0.1", "--desc", "Fixture Version", "--yes"},
|
||||
"dev app version get": {"dev", "app", "version", "get", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"dev app version list": {"dev", "app", "version", "list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"dev app version publish": {"dev", "app", "version", "publish", "--unified-app-id", "app-1", "--version-id", "version-1", "--yes"},
|
||||
"dev app version status": {"dev", "app", "version", "status", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"dev app webapp config": {"dev", "app", "webapp", "config", "--unified-app-id", "app-1", "--pc-homepage-url", "https://example.test/app", "--yes"},
|
||||
"dev app webapp get": {"dev", "app", "webapp", "get", "--unified-app-id", "app-1"},
|
||||
"dev connect restart": {"dev", "connect", "restart", "--robot-client-id", "robot-client-1"},
|
||||
"dev connect status": {"dev", "connect", "status", "--robot-client-id", "robot-client-1"},
|
||||
"dev connect stop": {"dev", "connect", "stop", "--robot-client-id", "robot-client-1"},
|
||||
"dev doc search": {"dev", "doc", "search", "--query", "fixture", "--page", "2"},
|
||||
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"devapp +create": {"devapp", "+create", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"devapp +delete": {"devapp", "+delete", "--unified-app-id", "app-1", "--yes"},
|
||||
"devapp +disable": {"devapp", "+disable", "--unified-app-id", "app-1", "--yes"},
|
||||
"devapp +enable": {"devapp", "+enable", "--unified-app-id", "app-1", "--yes"},
|
||||
"devapp +event-list": {"devapp", "+event-list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"devapp +get": {"devapp", "+get", "--unified-app-id", "app-1"},
|
||||
"devapp +list": {"devapp", "+list", "--app-key", "app-key-1"},
|
||||
"devapp +member-add": {"devapp", "+member-add", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"devapp +member-list": {"devapp", "+member-list", "--unified-app-id", "app-1", "--user-id", "user-1"},
|
||||
"devapp +member-remove": {"devapp", "+member-remove", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"devapp +permission-list": {"devapp", "+permission-list", "--unified-app-id", "app-1", "--api-status", "PUBLISHED", "--scope-type", "APP"},
|
||||
"devapp +robot-get": {"devapp", "+robot-get", "--unified-app-id", "app-1"},
|
||||
"devapp +update": {"devapp", "+update", "--unified-app-id", "app-1", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"devapp +version-check-approval": {"devapp", "+version-check-approval", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"devapp +version-get": {"devapp", "+version-get", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"devapp +version-list": {"devapp", "+version-list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"devapp +version-status": {"devapp", "+version-status", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"devapp +webapp-config": {"devapp", "+webapp-config", "--unified-app-id", "app-1", "--pc-homepage-url", "https://example.test/app", "--yes"},
|
||||
"devapp +webapp-get": {"devapp", "+webapp-get", "--unified-app-id", "app-1"},
|
||||
"event +listen-im": {"event", "+listen-im", "--user", "user-1", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
|
||||
"event consume": {"event", "consume", "--subscribe-id", "subscription-1", "--user", "user-1", "--group", "fixture-conversation", "--query", "fixture", "--output-dir", "/tmp/dws-event-fixture", "--filter-json", `{"rules":[]}`},
|
||||
"event list": {"event", "list", "--category", "im", "--include-pending"},
|
||||
"event schema": {"event", "schema", "--flatten"},
|
||||
"event status": {"event", "status", "--event", "im_message_received", "--status", "active", "--subscribe-id", "subscription-1"},
|
||||
"event stop": {"event", "stop", "--all", "--yes"},
|
||||
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
|
||||
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
@@ -335,6 +408,9 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
|
||||
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
|
||||
"report +template-search": {"report", "+template-search", "--query", "fixture"},
|
||||
"recruit job create": {"recruit", "job", "create", "--from", "testdata/recruit_job.json", "--yes"},
|
||||
"recruit job get": {"recruit", "job", "get", "--job-id", "job-1"},
|
||||
"recruit job list": {"recruit", "job", "list", "--job-ids", "job-1,job-2", "--creator-user-ids", "user-1,user-2", "--keyword", "fixture", "--cursor", "cursor-1", "--size", "7"},
|
||||
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
|
||||
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
|
||||
|
||||
@@ -397,6 +473,18 @@ var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"dev app get": {
|
||||
"app-key": {"dev", "app", "get", "--app-key", "app-key-1"},
|
||||
},
|
||||
"event +listen-im": {
|
||||
"open-dingtalk-id": {"event", "+listen-im", "--open-dingtalk-id", appFixtureCurrentDOpenID, "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
|
||||
"user-query": {"event", "+listen-im", "--user-query", "Fixture User", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
|
||||
"chat-id": {"event", "+listen-im", "--chat-id", "fixture-conversation", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
|
||||
"chat-query": {"event", "+listen-im", "--chat-query", "Fixture Group", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
|
||||
},
|
||||
"event consume": {
|
||||
"open-dingtalk-id": {"event", "consume", "--subscribe-id", "subscription-1", "--open-dingtalk-id", appFixtureCurrentDOpenID, "--group", "fixture-conversation", "--query", "fixture", "--output-dir", "/tmp/dws-event-fixture", "--filter-json", `{"rules":[]}`},
|
||||
},
|
||||
"markdown create": {
|
||||
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
},
|
||||
@@ -1106,7 +1194,8 @@ func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassCo
|
||||
product, _, _ := strings.Cut(fixture.Command, " ")
|
||||
switch product {
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat",
|
||||
"agoal", "audit", "dev", "devapp", "event", "mcp", "recruit":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@@ -1502,7 +1591,7 @@ func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
case "chat +messages-resource-download":
|
||||
return strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
case "drive +download", "drive +version-download":
|
||||
return strings.Contains(err.Error(), "下载地址必须是受信任域名上的 HTTPS URL")
|
||||
return strings.Contains(err.Error(), "下载地址必须是合法的 HTTPS URL")
|
||||
case "drive +upload":
|
||||
return strings.Contains(err.Error(), "incomplete drive upload credentials")
|
||||
default:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,788 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) {
|
||||
cmd := newCollegeContactCommand()
|
||||
|
||||
if cmd.Name() != "college-contact" {
|
||||
t.Errorf("expected name 'college-contact', got %q", cmd.Name())
|
||||
}
|
||||
if !cmd.Hidden {
|
||||
t.Error("extension root command should be Hidden")
|
||||
}
|
||||
|
||||
// 分组 → 叶子命令映射
|
||||
groups := map[string][]string{
|
||||
"dept": {
|
||||
"get-standard-structure", "get-detail", "get-chain", "search",
|
||||
"create", "update", "delete", "batch-update-type", "overview",
|
||||
},
|
||||
"employee": {
|
||||
"get-detail", "add", "remove", "change-type", "change-dept",
|
||||
"send-active-sms", "list-employees", "list-unaccepted",
|
||||
"list-unactive", "upgrade-status", "start-upgrade",
|
||||
},
|
||||
"alumni": {
|
||||
"get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins",
|
||||
},
|
||||
"graduate": {
|
||||
"query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore",
|
||||
},
|
||||
"group": {
|
||||
"query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule",
|
||||
},
|
||||
}
|
||||
|
||||
for groupName, leaves := range groups {
|
||||
var groupCmd *cobra.Command
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == groupName {
|
||||
groupCmd = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if groupCmd == nil {
|
||||
t.Fatalf("subcommand group %q not found", groupName)
|
||||
}
|
||||
for _, leaf := range leaves {
|
||||
found := false
|
||||
for _, c := range groupCmd.Commands() {
|
||||
if c.Name() == leaf {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("leaf command %q not found under %q", leaf, groupName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stats 分组已移除
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == "stats" {
|
||||
t.Error("subcommand group 'stats' should be removed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
|
||||
c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"})
|
||||
if err != nil {
|
||||
t.Fatalf("command path not found: %v", err)
|
||||
}
|
||||
if c.Name() != "get-standard-structure" {
|
||||
t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上
|
||||
// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newCollegeContactTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。
|
||||
// 单测环境未初始化 products 运行时依赖,若门禁放行后进入
|
||||
// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分
|
||||
// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。
|
||||
func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) {
|
||||
t.Helper()
|
||||
root := newCollegeContactTestRoot()
|
||||
root.SetArgs(args)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
panicked = true
|
||||
}
|
||||
}()
|
||||
err = root.Execute()
|
||||
return err, false
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"college-contact", "dept", "delete", "--dept-id", "12345"},
|
||||
{"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err, panicked := runDestructiveLeaf(t, args...)
|
||||
if panicked {
|
||||
t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("%v: 未传 --yes 应拒绝执行", args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--yes") {
|
||||
t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"},
|
||||
{"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
err, panicked := runDestructiveLeaf(t, args...)
|
||||
if !panicked {
|
||||
// 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败
|
||||
if err != nil && strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withCollegeContactCaller installs a dry-run capture caller so happy-path
|
||||
// command execution exercises each RunE up to the callMCPToolOnServer dispatch
|
||||
// without requiring a live MCP transport. In dry-run mode destructive
|
||||
// commands' confirm gate short-circuits to nil, so no --yes flag is needed.
|
||||
func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
// TestCollegeContactHappyPaths runs every leaf command with required flags only
|
||||
// and with all optional flags populated, expecting a nil error (dry-run preview).
|
||||
func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) {
|
||||
withCollegeContactCaller(t)
|
||||
|
||||
cases := [][]string{
|
||||
// ── dept ─────────────────────────────────────────────
|
||||
{"dept", "get-standard-structure"},
|
||||
{"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "get-detail", "--dept-id", "123"},
|
||||
{"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "get-chain", "--dept-id", "123"},
|
||||
{"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"dept", "search", "--dept-id", "123", "--keyword", "k"},
|
||||
{"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"},
|
||||
{"dept", "update", "--dept-id", "123", "--dept-type", "college"},
|
||||
{"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"},
|
||||
{"dept", "delete", "--dept-id", "123"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"},
|
||||
{"dept", "overview"},
|
||||
{"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
|
||||
// ── employee ─────────────────────────────────────────
|
||||
{"employee", "get-detail", "--staff-id", "S1"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"},
|
||||
{"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "remove", "--staff-ids", "S1"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "send-active-sms", "--dept-id", "100"},
|
||||
{"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-employees", "--dept-id", "123"},
|
||||
{"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "123"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "list-unactive", "--dept-id", "123"},
|
||||
{"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"},
|
||||
{"employee", "upgrade-status"},
|
||||
{"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"},
|
||||
{"employee", "start-upgrade"},
|
||||
|
||||
// ── alumni ───────────────────────────────────────────
|
||||
{"alumni", "get-dept-tree", "--alumni-dept-id", "123"},
|
||||
{"alumni", "get-info", "--alumni-dept-id", "123"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "query", "--staff-id", "S1"},
|
||||
{"alumni", "search", "--keyword", "x"},
|
||||
{"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"},
|
||||
{"alumni", "get-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"},
|
||||
{"alumni", "delete-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"alumni", "create-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "disband-group", "--alumni-dept-id", "1"},
|
||||
{"alumni", "get-alumni-org-from-graduate"},
|
||||
{"alumni", "create-alumni-org", "--org-name", "O"},
|
||||
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"},
|
||||
|
||||
// ── graduate ─────────────────────────────────────────
|
||||
{"graduate", "query-graduate-years"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"},
|
||||
{"graduate", "query-graduate-sub-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "get-task-result", "--request-no", "r1"},
|
||||
{"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"},
|
||||
{"graduate", "get-alumni-org"},
|
||||
{"graduate", "query-restore-sub-depts", "--dept-id", "1"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "search-graduate", "--keyword", "x"},
|
||||
{"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "2026"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"},
|
||||
|
||||
// ── group ────────────────────────────────────────────
|
||||
{"group", "query-group-rule"},
|
||||
{"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"},
|
||||
{"group", "get-group-rule-schedule"},
|
||||
{"group", "query-preview-data"},
|
||||
{"group", "query-preview-data", "--offset", "0", "--size", "20"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"},
|
||||
{"group", "delete-group-rule", "--rule-id", "1"},
|
||||
{"group", "enable-group-rule", "--rule-id", "1"},
|
||||
{"group", "disable-group-rule", "--rule-id", "1"},
|
||||
{"group", "set-group-rule-schedule"},
|
||||
{"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"},
|
||||
{"group", "execute-group-rule"},
|
||||
}
|
||||
|
||||
for _, args := range cases {
|
||||
root := newCollegeContactCommand()
|
||||
if err := executeCommand(root, args...); err != nil {
|
||||
t.Errorf("%v: expected nil error, got: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollegeContactValidationErrors exercises every validation-error branch:
|
||||
// missing required flags, non-integer int flags, invalid bool flags, and
|
||||
// empty-after-split CSV lists. Each case must return a non-nil error.
|
||||
func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) {
|
||||
withCollegeContactCaller(t)
|
||||
|
||||
cases := [][]string{
|
||||
// ── dept ─────────────────────────────────────────────
|
||||
{"dept", "get-standard-structure", "--dept-id", "abc"},
|
||||
{"dept", "get-standard-structure", "--offset", "abc"},
|
||||
{"dept", "get-standard-structure", "--size", "abc"},
|
||||
{"dept", "get-detail"},
|
||||
{"dept", "get-detail", "--dept-id", "abc"},
|
||||
{"dept", "get-detail", "--dept-id", "1", "--offset", "abc"},
|
||||
{"dept", "get-detail", "--dept-id", "1", "--size", "abc"},
|
||||
{"dept", "get-chain"},
|
||||
{"dept", "get-chain", "--dept-id", "abc"},
|
||||
{"dept", "get-chain", "--dept-id", "1", "--offset", "abc"},
|
||||
{"dept", "get-chain", "--dept-id", "1", "--size", "abc"},
|
||||
{"dept", "search"},
|
||||
{"dept", "search", "--dept-id", "abc", "--keyword", "k"},
|
||||
{"dept", "search", "--dept-id", "1"},
|
||||
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"},
|
||||
{"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"},
|
||||
{"dept", "create"},
|
||||
{"dept", "create", "--super-id", "abc"},
|
||||
{"dept", "create", "--super-id", "100"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"},
|
||||
{"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"},
|
||||
{"dept", "update"},
|
||||
{"dept", "update", "--dept-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"},
|
||||
{"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"},
|
||||
{"dept", "delete"},
|
||||
{"dept", "delete", "--dept-id", "abc"},
|
||||
{"dept", "batch-update-type"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"},
|
||||
{"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"},
|
||||
{"dept", "batch-update-type", "--dept-ids", "1,2"},
|
||||
{"dept", "overview", "--dept-id", "abc"},
|
||||
{"dept", "overview", "--offset", "abc"},
|
||||
{"dept", "overview", "--size", "abc"},
|
||||
|
||||
// ── employee ─────────────────────────────────────────
|
||||
{"employee", "get-detail"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"},
|
||||
{"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"},
|
||||
{"employee", "add"},
|
||||
{"employee", "add", "--emp-type", "x"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"},
|
||||
{"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"},
|
||||
{"employee", "remove"},
|
||||
{"employee", "remove", "--staff-ids", ",,"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"},
|
||||
{"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"},
|
||||
{"employee", "change-type"},
|
||||
{"employee", "change-type", "--staff-id", "S1"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"},
|
||||
{"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"},
|
||||
{"employee", "change-dept"},
|
||||
{"employee", "change-dept", "--staff-id", "S1"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"},
|
||||
{"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"},
|
||||
{"employee", "send-active-sms"},
|
||||
{"employee", "send-active-sms", "--dept-id", "abc"},
|
||||
{"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-employees"},
|
||||
{"employee", "list-employees", "--dept-id", "abc"},
|
||||
{"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-unaccepted"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "abc"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "list-unactive"},
|
||||
{"employee", "list-unactive", "--dept-id", "abc"},
|
||||
{"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"},
|
||||
{"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"},
|
||||
{"employee", "upgrade-status", "--dept-id", "abc"},
|
||||
{"employee", "upgrade-status", "--offset", "abc"},
|
||||
{"employee", "upgrade-status", "--size", "abc"},
|
||||
|
||||
// ── alumni ───────────────────────────────────────────
|
||||
{"alumni", "get-dept-tree"},
|
||||
{"alumni", "get-dept-tree", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "get-info"},
|
||||
{"alumni", "get-info", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "list"},
|
||||
{"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"},
|
||||
{"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"},
|
||||
{"alumni", "query"},
|
||||
{"alumni", "search"},
|
||||
{"alumni", "search", "--keyword", "x", "--offset", "abc"},
|
||||
{"alumni", "list-unaccepted"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"},
|
||||
{"alumni", "get-group"},
|
||||
{"alumni", "get-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "create-dept"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
|
||||
{"alumni", "create-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-dept"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"},
|
||||
{"alumni", "update-dept", "--alumni-dept-id", "1"},
|
||||
{"alumni", "delete-dept"},
|
||||
{"alumni", "delete-dept", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "update-managers"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1"},
|
||||
{"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"},
|
||||
{"alumni", "add-alumnus"},
|
||||
{"alumni", "add-alumnus", "--name", "X"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"},
|
||||
{"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"},
|
||||
{"alumni", "update-alumnus"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"},
|
||||
{"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"},
|
||||
{"alumni", "remove-alumnus"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1"},
|
||||
{"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "cancel-invite"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1"},
|
||||
{"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"},
|
||||
{"alumni", "create-group"},
|
||||
{"alumni", "create-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "disband-group"},
|
||||
{"alumni", "disband-group", "--alumni-dept-id", "abc"},
|
||||
{"alumni", "create-alumni-org"},
|
||||
{"alumni", "add-alumni-org-main-admins"},
|
||||
{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"},
|
||||
|
||||
// ── graduate ─────────────────────────────────────────
|
||||
{"graduate", "query-graduate-depts"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"},
|
||||
{"graduate", "query-graduate-sub-depts"},
|
||||
{"graduate", "query-graduate-sub-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-page-graduate-users"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "abc"},
|
||||
{"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"},
|
||||
{"graduate", "get-task-result"},
|
||||
{"graduate", "query-restore-sub-depts"},
|
||||
{"graduate", "query-restore-sub-depts", "--dept-id", "abc"},
|
||||
{"graduate", "query-dept-deleted-emps"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "abc"},
|
||||
{"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"},
|
||||
{"graduate", "search-graduate"},
|
||||
{"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"},
|
||||
{"graduate", "commit-graduate"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "abc"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", ",,"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"},
|
||||
{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"},
|
||||
{"graduate", "all-graduate"},
|
||||
{"graduate", "all-graduate", "--graduate-year", "abc"},
|
||||
{"graduate", "batch-graduate"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "abc"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1"},
|
||||
{"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "delete-and-graduate"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "abc"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1"},
|
||||
{"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-delete-pending"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "abc"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1"},
|
||||
{"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-update-pending"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "abc"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"},
|
||||
{"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"},
|
||||
{"graduate", "commit-restore"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", "abc"},
|
||||
{"graduate", "commit-restore", "--graduate-dept-ids", ",,"},
|
||||
|
||||
// ── group ────────────────────────────────────────────
|
||||
{"group", "query-group-rule", "--offset", "abc"},
|
||||
{"group", "query-group-rule", "--size", "abc"},
|
||||
{"group", "query-preview-data", "--offset", "abc"},
|
||||
{"group", "query-preview-data", "--size", "abc"},
|
||||
{"group", "create-group-rule"},
|
||||
{"group", "create-group-rule", "--name", "X"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T"},
|
||||
{"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"},
|
||||
{"group", "delete-group-rule"},
|
||||
{"group", "delete-group-rule", "--rule-id", "abc"},
|
||||
{"group", "enable-group-rule"},
|
||||
{"group", "enable-group-rule", "--rule-id", "abc"},
|
||||
{"group", "disable-group-rule"},
|
||||
{"group", "disable-group-rule", "--rule-id", "abc"},
|
||||
}
|
||||
|
||||
for _, args := range cases {
|
||||
root := newCollegeContactCommand()
|
||||
if err := executeCommand(root, args...); err == nil {
|
||||
t.Errorf("%v: expected non-nil error, got nil", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every
|
||||
// user_required destructive leaf in a paired manner:
|
||||
// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls).
|
||||
// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct
|
||||
// productID, tool name, and complete argument payload.
|
||||
func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) {
|
||||
type destructiveCase struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}
|
||||
|
||||
cases := []destructiveCase{
|
||||
{
|
||||
"dept delete",
|
||||
[]string{"college-contact", "dept", "delete", "--dept-id", "123"},
|
||||
"delete_college_contact_dept",
|
||||
map[string]any{"deptId": int64(123)},
|
||||
},
|
||||
{
|
||||
"employee remove",
|
||||
[]string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"},
|
||||
"remove_employee",
|
||||
map[string]any{"staffIds": []string{"S1", "S2"}},
|
||||
},
|
||||
{
|
||||
"alumni delete-dept",
|
||||
[]string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"},
|
||||
"delete_alumni_dept",
|
||||
map[string]any{"alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"alumni remove-alumnus",
|
||||
[]string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"},
|
||||
"delete_alumnus",
|
||||
map[string]any{"staffId": "S1", "alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"alumni cancel-invite",
|
||||
[]string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"delete_alumni_invite_record",
|
||||
map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"alumni disband-group",
|
||||
[]string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"},
|
||||
"disband_alumni_group",
|
||||
map[string]any{"alumniDeptId": int64(1)},
|
||||
},
|
||||
{
|
||||
"graduate commit-graduate",
|
||||
[]string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"},
|
||||
"commit_graduate",
|
||||
map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate all-graduate",
|
||||
[]string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"},
|
||||
"all_graduate",
|
||||
map[string]any{"graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate batch-graduate",
|
||||
[]string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"batch_graduate",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate delete-and-graduate",
|
||||
[]string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"delete_and_graduate",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate batch-delete-pending",
|
||||
[]string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"},
|
||||
"batch_delete_pending",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}},
|
||||
},
|
||||
{
|
||||
"graduate batch-update-pending",
|
||||
[]string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"},
|
||||
"batch_update_pending",
|
||||
map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)},
|
||||
},
|
||||
{
|
||||
"graduate commit-restore",
|
||||
[]string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"},
|
||||
"commit_restore",
|
||||
map[string]any{"graduateDeptIds": []int64{1, 2}},
|
||||
},
|
||||
{
|
||||
"group delete-group-rule",
|
||||
[]string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"},
|
||||
"delete_group_rule",
|
||||
map[string]any{"ruleId": int64(1)},
|
||||
},
|
||||
{
|
||||
"group execute-group-rule",
|
||||
[]string{"college-contact", "group", "execute-group-rule"},
|
||||
"execute_group_rule",
|
||||
map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newCollegeContactTestRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newCollegeContactTestRoot()
|
||||
argsWithYes := append(append([]string{}, tc.args...), "--yes")
|
||||
root.SetArgs(argsWithYes)
|
||||
err := root.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "college-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// withCollegeContactDispatchCaller installs a non-dry-run capture caller so
|
||||
// commands go through the full dispatch path (deps.Caller.CallTool) and we can
|
||||
// verify the productID, tool name, and args passed to callMCPToolOnServer.
|
||||
func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
// TestCollegeContactDispatch verifies that representative commands from each
|
||||
// group dispatch to the correct MCP tool with the expected productID and args.
|
||||
func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) {
|
||||
type dispatchCase struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantProd string
|
||||
checkArgs func(t *testing.T, args map[string]any)
|
||||
}
|
||||
|
||||
cases := []dispatchCase{
|
||||
{
|
||||
name: "dept get-standard-structure",
|
||||
args: []string{"college-contact", "dept", "get-standard-structure"},
|
||||
wantTool: "get_college_standard_structure",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "dept get-detail",
|
||||
args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"},
|
||||
wantTool: "get_college_dept_detail",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dept create",
|
||||
args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"},
|
||||
wantTool: "create_college_contact_dept",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "employee get-detail",
|
||||
args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"},
|
||||
wantTool: "get_employee_detail",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["staffId"] != "S1" {
|
||||
t.Errorf("staffId = %v, want S1", input["staffId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alumni get-dept-tree",
|
||||
args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"},
|
||||
wantTool: "get_alumni_dept_tree",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["alumniDeptId"] != int64(123) {
|
||||
t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "graduate query-graduate-years",
|
||||
args: []string{"college-contact", "graduate", "query-graduate-years"},
|
||||
wantTool: "query_graduate_years",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "group query-group-rule",
|
||||
args: []string{"college-contact", "group", "query-group-rule"},
|
||||
wantTool: "query_group_rule",
|
||||
wantProd: "college-contact",
|
||||
},
|
||||
{
|
||||
name: "dept delete with --yes",
|
||||
args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"},
|
||||
wantTool: "delete_college_contact_dept",
|
||||
wantProd: "college-contact",
|
||||
checkArgs: func(t *testing.T, args map[string]any) {
|
||||
input := args["input"].(map[string]any)
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := withCollegeContactDispatchCaller(t)
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newCollegeContactCommand())
|
||||
root.SetArgs(tc.args)
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if caller.productID != tc.wantProd {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd)
|
||||
}
|
||||
if caller.tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool)
|
||||
}
|
||||
if tc.checkArgs != nil {
|
||||
tc.checkArgs(t, caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// initialChunkSize is the first attempted chunk size (rune count).
|
||||
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
|
||||
initialChunkSize = 10000
|
||||
// DefaultMarkdownChunkRunes is the single source of truth for the markdown
|
||||
// append-mode chunk limit (rune count), shared by every write path that
|
||||
// chunks. The splitter budgets any injected repair (a re-emitted table
|
||||
// header, a reopened fence) against this limit, so a repaired chunk is still
|
||||
// guaranteed to be at most this many runes.
|
||||
DefaultMarkdownChunkRunes = 30000
|
||||
|
||||
// longContentWarningThreshold triggers a hint to use --content-file.
|
||||
longContentWarningThreshold = 2048
|
||||
@@ -49,10 +52,22 @@ func detectContentSource(cmd *cobra.Command) contentInputSource {
|
||||
|
||||
// DocWriteResult is the structured output of the write pipeline.
|
||||
type DocWriteResult struct {
|
||||
Success bool `json:"success"`
|
||||
NodeID string `json:"nodeId"`
|
||||
ChunksWritten int `json:"chunksWritten"`
|
||||
ServerResponse json.RawMessage `json:"serverResponse,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
NodeID string `json:"nodeId"`
|
||||
ChunksWritten int `json:"chunksWritten"`
|
||||
// Degradations lists the chunk boundaries that changed the rendered
|
||||
// structure. Empty means the document reads exactly as the input did.
|
||||
Degradations []MarkdownDegradation `json:"degradations,omitempty"`
|
||||
ServerResponse json.RawMessage `json:"serverResponse,omitempty"`
|
||||
}
|
||||
|
||||
// chunkedWriteOutcome is what a chunked write reports back. It is a struct rather
|
||||
// than another return value because the tuple was already four wide.
|
||||
type chunkedWriteOutcome struct {
|
||||
nodeID string
|
||||
written int
|
||||
lastResponse string
|
||||
degradations []MarkdownDegradation
|
||||
}
|
||||
|
||||
// docWritePipeline is the unified entry point for doc create/update with
|
||||
@@ -61,7 +76,7 @@ type DocWriteResult struct {
|
||||
// Phases:
|
||||
//
|
||||
// 0. Pre-check: warn if --content literal is long
|
||||
// 1. Strategy: single write (≤initialChunkSize) or chunked
|
||||
// 1. Strategy: single write (≤DefaultMarkdownChunkRunes) or chunked
|
||||
// 2. Write: single call or adaptive chunked writes
|
||||
// 3. Output: JSON result
|
||||
func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]any,
|
||||
@@ -84,25 +99,37 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
|
||||
defer stop()
|
||||
|
||||
// Phase 1+2: strategy selection and write
|
||||
var nodeID string
|
||||
var chunksWritten int
|
||||
var lastResponse string
|
||||
var outcome chunkedWriteOutcome
|
||||
var writeErr error
|
||||
|
||||
if markdown == "" || runeCount <= initialChunkSize {
|
||||
if markdown == "" || runeCount <= DefaultMarkdownChunkRunes {
|
||||
// Single write path
|
||||
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
|
||||
chunksWritten = 1
|
||||
outcome.nodeID, outcome.lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
|
||||
outcome.written = 1
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) {
|
||||
// The server may have committed the write before the client observed the
|
||||
// timeout. Replaying create/append here can duplicate a document or
|
||||
// content, so fail closed and require inspection before any retry.
|
||||
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
|
||||
writeErr = docWriteUnknownStateError(operation, outcome.nodeID, "single_write", 0, 1, writeErr, nil)
|
||||
}
|
||||
} else {
|
||||
// Chunked write path
|
||||
// Chunked write path. --index cannot survive chunking: each chunk creates
|
||||
// an unpredictable number of blocks, so the insertion point for chunk 2
|
||||
// is unknowable. Fail closed rather than silently ignore the flag.
|
||||
if _, hasIndex := toolArgs["index"]; hasIndex {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("内容长度 %d 字符超过单次写入上限 %d,需要自动分片,而 --index 在分片写入下无法保证插入位置", runeCount, DefaultMarkdownChunkRunes),
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("doc_write_index_with_chunking"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions(
|
||||
"去掉 --index 追加到文档末尾",
|
||||
"或把内容拆成小于上限的多段,各自带 --index 分别写入",
|
||||
),
|
||||
)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 内容较长 (%d 字符),自动分片写入...", runeCount))
|
||||
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize)
|
||||
outcome, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, DefaultMarkdownChunkRunes)
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
@@ -112,11 +139,12 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
|
||||
// Phase 3: output
|
||||
result := DocWriteResult{
|
||||
Success: true,
|
||||
NodeID: nodeID,
|
||||
ChunksWritten: chunksWritten,
|
||||
NodeID: outcome.nodeID,
|
||||
ChunksWritten: outcome.written,
|
||||
Degradations: outcome.degradations,
|
||||
}
|
||||
if json.Valid([]byte(lastResponse)) {
|
||||
result.ServerResponse = json.RawMessage(lastResponse)
|
||||
if json.Valid([]byte(outcome.lastResponse)) {
|
||||
result.ServerResponse = json.RawMessage(outcome.lastResponse)
|
||||
}
|
||||
return deps.Out.PrintJSON(result)
|
||||
}
|
||||
@@ -131,18 +159,19 @@ func singleWrite(ctx context.Context, toolName string, toolArgs map[string]any)
|
||||
return nodeID, resultText, nil
|
||||
}
|
||||
|
||||
// chunkedWrite performs adaptive chunked writing.
|
||||
// For doc create: first chunk creates the document directly (with content), rest append.
|
||||
// For doc update with overwrite: first chunk uses overwrite, rest use append.
|
||||
// Returns nodeID, chunks written, last server response text, and error.
|
||||
// chunkedWrite writes markdown as a sequence of independently valid chunks.
|
||||
// For doc create: the first chunk creates the document directly (with content),
|
||||
// the rest append. For doc update with overwrite: the first chunk uses overwrite,
|
||||
// the rest use append.
|
||||
func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
markdown string, operation string, startChunkSize int) (string, int, string, error) {
|
||||
markdown string, operation string, chunkSize int) (chunkedWriteOutcome, error) {
|
||||
|
||||
var nodeID string
|
||||
var lastResponse string
|
||||
chunkSize := startChunkSize
|
||||
chunks := splitMarkdownSafe(markdown, chunkSize)
|
||||
writtenCount := 0
|
||||
plan := SplitMarkdownForAppend(markdown, chunkSize)
|
||||
chunks := plan.Chunks
|
||||
out := chunkedWriteOutcome{degradations: plan.Degradations}
|
||||
for _, warning := range plan.Warnings() {
|
||||
deps.Out.PrintInfo("[WARN] " + warning)
|
||||
}
|
||||
|
||||
// --- Write first chunk ---
|
||||
if toolName == "create_document" {
|
||||
@@ -154,81 +183,89 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (1/%d),%d 字符 (create)...",
|
||||
len(chunks), utf8.RuneCountInString(chunks[0])))
|
||||
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
|
||||
out.lastResponse = resultText
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
|
||||
return out, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err, plan.Degradations)
|
||||
}
|
||||
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
|
||||
return out, fmt.Errorf("创建文档失败: %w", err)
|
||||
}
|
||||
nodeID = extractNodeIDFromResult(resultText)
|
||||
if nodeID == "" {
|
||||
return "", 0, resultText, fmt.Errorf("创建文档成功但无法提取 nodeId")
|
||||
out.nodeID = extractNodeIDFromResult(resultText)
|
||||
if out.nodeID == "" {
|
||||
return out, fmt.Errorf("创建文档成功但无法提取 nodeId")
|
||||
}
|
||||
lastResponse = resultText
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 文档已创建 (nodeId=%s)", nodeID))
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 文档已创建 (nodeId=%s)", out.nodeID))
|
||||
} else {
|
||||
if id, ok := toolArgs["nodeId"].(string); ok {
|
||||
nodeID = id
|
||||
out.nodeID = id
|
||||
}
|
||||
firstMode := "append"
|
||||
if m, ok := toolArgs["mode"].(string); ok {
|
||||
firstMode = m
|
||||
}
|
||||
updateArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"nodeId": out.nodeID,
|
||||
"markdown": chunks[0],
|
||||
"mode": firstMode,
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (1/%d),%d 字符 (%s)...",
|
||||
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
out.lastResponse = resultText
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
|
||||
return out, docWriteUnknownStateError(operation, out.nodeID, "chunk_1", 0, len(chunks), err, plan.Degradations)
|
||||
}
|
||||
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
|
||||
return out, fmt.Errorf("第 1 片写入失败: %w", err)
|
||||
}
|
||||
lastResponse = resultText
|
||||
}
|
||||
writtenCount = 1
|
||||
out.written = 1
|
||||
|
||||
// --- Write remaining chunks with append ---
|
||||
for i := 1; i < len(chunks); i++ {
|
||||
if ctx.Err() != nil {
|
||||
return nodeID, writtenCount, lastResponse, fmt.Errorf("写入被中断,已完成 %d/%d 片", writtenCount, len(chunks))
|
||||
return out, fmt.Errorf("写入被中断,已完成 %d/%d 片", out.written, len(chunks))
|
||||
}
|
||||
|
||||
chunk := chunks[i]
|
||||
preview := chunk
|
||||
if len(preview) > 80 {
|
||||
preview = preview[:80]
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入分片 (%d/%d),%d 字符, preview=[%s]...",
|
||||
i+1, len(chunks), utf8.RuneCountInString(chunk), preview))
|
||||
i+1, len(chunks), utf8.RuneCountInString(chunk), previewRunes(chunk, 80)))
|
||||
|
||||
updateArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"nodeId": out.nodeID,
|
||||
"markdown": chunk,
|
||||
"mode": "append",
|
||||
}
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
out.lastResponse = resultText
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
|
||||
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
|
||||
return out, docWriteUnknownStateError(
|
||||
operation, out.nodeID, fmt.Sprintf("chunk_%d", i+1), out.written, len(chunks), err, plan.Degradations,
|
||||
)
|
||||
}
|
||||
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
|
||||
return out, fmt.Errorf("分片 %d 写入失败: %w", out.written+1, err)
|
||||
}
|
||||
lastResponse = resultText
|
||||
writtenCount++
|
||||
out.written++
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 全部 %d 个分片写入完成", writtenCount))
|
||||
return nodeID, writtenCount, lastResponse, nil
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 全部 %d 个分片写入完成", out.written))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
|
||||
// previewRunes truncates to at most n runes. Slicing by byte would cut a
|
||||
// multi-byte character in half and put invalid UTF-8 into the log line.
|
||||
func previewRunes(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n])
|
||||
}
|
||||
|
||||
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int,
|
||||
cause error, degradations []MarkdownDegradation) error {
|
||||
|
||||
details := map[string]any{
|
||||
"status": "unknown",
|
||||
"nodeId": nodeID,
|
||||
@@ -236,6 +273,12 @@ func docWriteUnknownStateError(operation, nodeID, stage string, written, total i
|
||||
"chunksTotal": total,
|
||||
"failedStage": stage,
|
||||
}
|
||||
if len(degradations) > 0 {
|
||||
// Resuming safely needs to know which boundaries carried injected repair
|
||||
// text, because a chunk that begins with a repeated table header is not
|
||||
// the same as the raw source at that offset.
|
||||
details["degradations"] = degradations
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
|
||||
apperrors.WithOperation(operation),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -49,13 +51,104 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
|
||||
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
|
||||
}
|
||||
|
||||
chunked := strings.Repeat("x", initialChunkSize+100)
|
||||
chunked := strings.Repeat("x", DefaultMarkdownChunkRunes+100)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}}})
|
||||
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "markdown": chunked}, chunked, "update"); err != nil {
|
||||
t.Fatalf("long content chunking: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWriteRejectsIndexWhenChunking(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
// --index cannot be propagated across chunks: each chunk creates an unknown
|
||||
// number of blocks, so the insertion point for chunk 2 is unknowable. Before
|
||||
// this guard, chunkedWrite rebuilt the tool args with only nodeId/markdown/mode
|
||||
// and the flag was silently dropped.
|
||||
long := strings.Repeat("x", DefaultMarkdownChunkRunes+10)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := docWritePipeline(docWriteCoverageCommand(), "update_document",
|
||||
map[string]any{"nodeId": "node", "mode": "append", "index": 3}, long, "update")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_index_with_chunking" {
|
||||
t.Fatalf("expected a fail-closed validation error, got %#v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("must reject before writing anything, calls=%d", caller.calls)
|
||||
}
|
||||
|
||||
// The same args below the limit stay allowed, so the guard is scoped to
|
||||
// chunking rather than banning --index outright.
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}})
|
||||
if err := docWritePipeline(docWriteCoverageCommand(), "update_document",
|
||||
map[string]any{"nodeId": "node", "mode": "append", "index": 3}, "short", "update"); err != nil {
|
||||
t.Fatalf("short content with --index must still write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWriteSurfacesDegradations(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
// An oversized table must be split with its header repeated, and the caller
|
||||
// must be told so — silently turning one table into three would otherwise
|
||||
// look like a clean write.
|
||||
rows := strings.Repeat("| 张三 | 技术部 | 10086 |\n", 4000)
|
||||
content := "| 姓名 | 部门 | 工号 |\n|---|---|---|\n" + rows
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {text: `{}`}, {text: `{}`}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
// installScriptedCaller initializes deps and discards output; capture stdout
|
||||
// afterwards so the result JSON can be inspected.
|
||||
var stdout bytes.Buffer
|
||||
deps.Out = NewFormatterWithWriters(&stdout, &bytes.Buffer{})
|
||||
if err := docWritePipeline(docWriteCoverageCommand(), "update_document",
|
||||
map[string]any{"nodeId": "node", "mode": "overwrite"}, content, "update"); err != nil {
|
||||
t.Fatalf("chunked table write: %v", err)
|
||||
}
|
||||
|
||||
// The result JSON follows the [INFO]/[WARN] progress lines on the same stream.
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "[WARN] 内容过长已分片") {
|
||||
t.Errorf("no warning line for the table split: %q", out)
|
||||
}
|
||||
var result DocWriteResult
|
||||
if err := json.Unmarshal([]byte(out[strings.Index(out, "{"):]), &result); err != nil {
|
||||
t.Fatalf("decode result: %v (stdout=%q)", err, out)
|
||||
}
|
||||
if result.ChunksWritten < 2 {
|
||||
t.Fatalf("expected a chunked write, got %#v", result)
|
||||
}
|
||||
if len(result.Degradations) == 0 {
|
||||
t.Fatalf("table split was not reported: %#v", result)
|
||||
}
|
||||
for _, d := range result.Degradations {
|
||||
if d.Kind != "table_split" || !strings.HasPrefix(d.InjectedPrefix, "| 姓名 ") {
|
||||
t.Errorf("degradation = %#v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWritePreviewTruncatesByRune(t *testing.T) {
|
||||
// Slicing by byte would split a multi-byte character and put invalid UTF-8
|
||||
// into the progress line.
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"abc", 5, "abc"},
|
||||
{"abcdef", 3, "abc"},
|
||||
{strings.Repeat("界", 5), 2, "界界"},
|
||||
} {
|
||||
if got := previewRunes(tc.in, tc.n); got != tc.want {
|
||||
t.Errorf("previewRunes(%q,%d) = %q, want %q", tc.in, tc.n, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
@@ -63,9 +156,9 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
|
||||
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
|
||||
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
|
||||
outcome, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
|
||||
if err == nil || outcome.written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
|
||||
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", outcome.written, caller.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +180,13 @@ func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
|
||||
outcome, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
|
||||
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
|
||||
if outcome.nodeID != tc.wantNode || outcome.written != 0 || caller.calls != 1 {
|
||||
t.Fatalf("node=%q written=%d calls=%d", outcome.nodeID, outcome.written, caller.calls)
|
||||
}
|
||||
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("unknown commit metadata = %#v", typed)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,503 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func withEduAppCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func runEduApp(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag
|
||||
// populated, so all optional-field branches and the dispatch line are covered.
|
||||
func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"},
|
||||
|
||||
{"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"},
|
||||
{"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"},
|
||||
{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"},
|
||||
|
||||
{"report", "get", "--ids", "1001,1002"},
|
||||
{"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"},
|
||||
{"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"},
|
||||
{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"},
|
||||
{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"},
|
||||
|
||||
{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"},
|
||||
{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假",
|
||||
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
|
||||
"--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true",
|
||||
"--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29",
|
||||
"--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s",
|
||||
"--attributes", `{"k":"v"}`, "--user-name", "张三"},
|
||||
{"notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
|
||||
{"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
{"notice", "get", "--notice-id", "12345", "--user-name", "张三"},
|
||||
{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
|
||||
{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"},
|
||||
|
||||
{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"},
|
||||
{"card", "end", "--card-id", "1"},
|
||||
{"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"},
|
||||
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"},
|
||||
{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"},
|
||||
|
||||
{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三",
|
||||
"--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29",
|
||||
"--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl",
|
||||
"--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`},
|
||||
{"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"},
|
||||
{"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"},
|
||||
{"diploma", "get", "--diploma-id", "1", "--user-name", "张三"},
|
||||
{"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"},
|
||||
{"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"},
|
||||
{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"},
|
||||
|
||||
{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习",
|
||||
"--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v",
|
||||
"--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`,
|
||||
"--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s",
|
||||
"--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW",
|
||||
"--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"},
|
||||
{"homework", "delete", "--homework-id", "1", "--user-name", "张三"},
|
||||
{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c",
|
||||
"--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"},
|
||||
{"homework", "get", "--homework-id", "1", "--user-name", "张三"},
|
||||
{"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"},
|
||||
{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"},
|
||||
{"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"},
|
||||
{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2",
|
||||
"--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduAppCaller(t)
|
||||
if err := runEduApp(t, args...); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 /
|
||||
// page-size<=0 / the >0 else arms) that the positive-value happy paths skip.
|
||||
func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"report", "by-teacher", "--page", "0", "--limit", "0"},
|
||||
{"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"},
|
||||
{"notice", "list-by-teacher", "--page", "0", "--page-size", "0"},
|
||||
{"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"},
|
||||
{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"},
|
||||
{"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"},
|
||||
{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"},
|
||||
{"diploma", "list-by-teacher", "--page", "0", "--limit", "0"},
|
||||
{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"},
|
||||
{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"},
|
||||
{"homework", "list-by-teacher", "--page", "0", "--page-size", "0"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduAppCaller(t)
|
||||
if err := runEduApp(t, args...); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"},
|
||||
{"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"},
|
||||
{"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"},
|
||||
{"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"},
|
||||
{"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"},
|
||||
{"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"},
|
||||
|
||||
{"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"},
|
||||
{"student-list missing students", []string{"task", "student-list"}, "students"},
|
||||
{"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"},
|
||||
|
||||
{"report get missing ids", []string{"report", "get"}, "ids"},
|
||||
{"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"},
|
||||
{"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"},
|
||||
{"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"},
|
||||
{"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"},
|
||||
{"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"},
|
||||
{"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"},
|
||||
{"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"},
|
||||
{"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"},
|
||||
{"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"},
|
||||
{"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"},
|
||||
|
||||
{"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"},
|
||||
{"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"},
|
||||
{"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"},
|
||||
{"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"},
|
||||
{"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"},
|
||||
{"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"},
|
||||
{"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"},
|
||||
{"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"},
|
||||
{"notice get missing notice-id", []string{"notice", "get"}, "notice-id"},
|
||||
{"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"},
|
||||
{"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"},
|
||||
{"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"},
|
||||
{"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"},
|
||||
|
||||
{"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"},
|
||||
{"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"},
|
||||
{"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"},
|
||||
|
||||
{"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"},
|
||||
{"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"},
|
||||
{"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"},
|
||||
{"card end missing card-id", []string{"card", "end"}, "card-id"},
|
||||
{"card list missing status", []string{"card", "list"}, "status"},
|
||||
{"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"},
|
||||
{"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"},
|
||||
{"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"},
|
||||
{"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"},
|
||||
{"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"},
|
||||
{"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"},
|
||||
{"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"},
|
||||
|
||||
{"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"},
|
||||
{"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"},
|
||||
{"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"},
|
||||
{"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"},
|
||||
{"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"},
|
||||
{"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"},
|
||||
{"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"},
|
||||
{"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"},
|
||||
{"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"},
|
||||
{"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"},
|
||||
{"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"},
|
||||
{"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"},
|
||||
{"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"},
|
||||
{"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"},
|
||||
{"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"},
|
||||
|
||||
{"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"},
|
||||
{"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"},
|
||||
{"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"},
|
||||
{"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"},
|
||||
{"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"},
|
||||
{"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"},
|
||||
{"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"},
|
||||
{"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"},
|
||||
{"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"},
|
||||
{"homework get missing homework-id", []string{"homework", "get"}, "homework-id"},
|
||||
{"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"},
|
||||
{"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"},
|
||||
{"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"},
|
||||
{"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"},
|
||||
{"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"},
|
||||
{"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"},
|
||||
{"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"},
|
||||
{"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"},
|
||||
{"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"},
|
||||
{"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"},
|
||||
{"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"},
|
||||
{"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"},
|
||||
{"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"},
|
||||
{"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withEduAppCaller(t)
|
||||
err := runEduApp(t, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) {
|
||||
if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("eduAppParseCSV = %#v", got)
|
||||
}
|
||||
ids, err := eduAppParseIntCSV(" 1 , , 2 ")
|
||||
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err)
|
||||
}
|
||||
if _, err := eduAppParseIntCSV("1,bad"); err == nil {
|
||||
t.Fatalf("eduAppParseIntCSV invalid = nil error")
|
||||
}
|
||||
}
|
||||
|
||||
func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) {
|
||||
t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "get_ai_message_summary_list" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["classId"] != int64(100) {
|
||||
t.Fatalf("classId = %v, want 100", input["classId"])
|
||||
}
|
||||
if input["cid"] != "cidxxx" {
|
||||
t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx")
|
||||
}
|
||||
if input["targetRole"] != "guardian" {
|
||||
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
|
||||
}
|
||||
if input["status"] != int64(1) {
|
||||
t.Fatalf("status = %v, want 1", input["status"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("report get dispatches get_report", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "get_report" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "get_report")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
ids, ok := input["schoolReportIdList"].([]int64)
|
||||
if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 {
|
||||
t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "query_student_circle_posts" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["classId"] != "12345" {
|
||||
t.Fatalf("classId = %v, want %q", input["classId"], "12345")
|
||||
}
|
||||
if input["studentId"] != "stu1" {
|
||||
t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1")
|
||||
}
|
||||
if input["targetRole"] != "guardian" {
|
||||
t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card end dispatches end_card", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"card", "end", "--card-id", "999"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "end_card" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "end_card")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["cardId"] != int64(999) {
|
||||
t.Fatalf("cardId = %v, want 999", input["cardId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card update dispatches update_card", func(t *testing.T) {
|
||||
caller := withEduAppDispatchCaller(t)
|
||||
cmd := newEduAppCommand()
|
||||
cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-app" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-app")
|
||||
}
|
||||
if caller.tool != "update_card" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "update_card")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["cardId"] != int64(77) {
|
||||
t.Fatalf("cardId = %v, want 77", input["cardId"])
|
||||
}
|
||||
if input["identifier"] != "org1-staff1-uuid" {
|
||||
t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid")
|
||||
}
|
||||
if input["title"] != "新标题" {
|
||||
t.Fatalf("title = %v, want %q", input["title"], "新标题")
|
||||
}
|
||||
if input["shouldSendUpdateMsg"] != true {
|
||||
t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
|
||||
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newEduAppConfirmRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newEduAppCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个
|
||||
// user_required 破坏性叶子做成对验证:
|
||||
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
|
||||
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
|
||||
func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
"notice delete",
|
||||
[]string{"edu-app", "notice", "delete", "--notice-id", "12345"},
|
||||
"delete_notice",
|
||||
map[string]any{"noticeId": int64(12345)},
|
||||
},
|
||||
{
|
||||
"notice delete with user-name",
|
||||
[]string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"},
|
||||
"delete_notice",
|
||||
map[string]any{"noticeId": int64(12345), "userName": "张三"},
|
||||
},
|
||||
{
|
||||
"homework delete",
|
||||
[]string{"edu-app", "homework", "delete", "--homework-id", "12345"},
|
||||
"delete_homework",
|
||||
map[string]any{"homeworkId": int64(12345)},
|
||||
},
|
||||
{
|
||||
"diploma delete",
|
||||
[]string{"edu-app", "diploma", "delete", "--diploma-id", "12345"},
|
||||
"delete_diploma",
|
||||
map[string]any{"diplomaId": int64(12345)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduAppConfirmRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduAppConfirmRoot()
|
||||
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "edu-app" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,672 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestEduContactRoot() *cobra.Command {
|
||||
return newEduContactCommand()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 命令注册测试 — 验证所有子命令路径是否正确注册
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
|
||||
paths := [][]string{
|
||||
// school
|
||||
{"school", "roles"},
|
||||
{"school", "structure"},
|
||||
{"school", "periods"},
|
||||
{"school", "type"},
|
||||
{"school", "stats"},
|
||||
{"school", "class-list"},
|
||||
// class — 原有
|
||||
{"class", "detail"},
|
||||
{"class", "students"},
|
||||
{"class", "teachers"},
|
||||
{"class", "same-name"},
|
||||
{"class", "user-role"},
|
||||
{"class", "search-by-name"},
|
||||
{"class", "headmaster"},
|
||||
// class — 新增
|
||||
{"class", "search-by-teacher"},
|
||||
{"class", "add-student"},
|
||||
{"class", "add-teachers"},
|
||||
{"class", "add-unofficial-student"},
|
||||
{"class", "delete-students"},
|
||||
{"class", "delete-teacher"},
|
||||
{"class", "modify-student-info"},
|
||||
{"class", "move-student"},
|
||||
{"class", "update-info"},
|
||||
{"class", "update-student"},
|
||||
{"class", "update-student-mobile"},
|
||||
{"class", "update-student-number"},
|
||||
// family
|
||||
{"family", "children"},
|
||||
{"family", "parents"},
|
||||
// teacher
|
||||
{"teacher", "classes"},
|
||||
{"teacher", "update-course"},
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if _, _, err := root.Find(path); err != nil {
|
||||
t.Errorf("command path %v not found: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// 参数校验测试 — 验证必填参数缺失时返回错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func executeCommand(root *cobra.Command, args ...string) error {
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "search-by-teacher")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --dept-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --teacher-user-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --is-adviser, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --origin-class-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-user-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --mobile, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --dept-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "delete-students", "--dept-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-user-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --dept-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-staff-ids, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --class-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-user-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --patriarchs JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-number, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-info", "--nick", "火箭班")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --class-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --group-name without --conversation-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --teacher-user-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --patriarch-user-id without --relation, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --student-name, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing mobile (student or parent), got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --mother JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "teacher", "update-course")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --teacher-class-infos, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) {
|
||||
root := newTestEduContactRoot()
|
||||
err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty --teacher-class-infos array, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller)
|
||||
// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// withEduContactCaller installs a dry-run capture caller so happy-path command
|
||||
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
|
||||
// requiring a live MCP transport.
|
||||
func withEduContactCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) {
|
||||
withEduContactCaller(t)
|
||||
cases := [][]string{
|
||||
// school
|
||||
{"school", "roles"},
|
||||
{"school", "structure"},
|
||||
{"school", "periods"},
|
||||
{"school", "type"},
|
||||
{"school", "stats"},
|
||||
{"school", "stats", "--statistics-type", "1"},
|
||||
{"school", "class-list"},
|
||||
// class — 读操作
|
||||
{"class", "detail", "--dept-id", "123"},
|
||||
{"class", "students", "--dept-id", "123"},
|
||||
{"class", "teachers", "--dept-id", "123"},
|
||||
{"class", "same-name", "--dept-id", "123"},
|
||||
{"class", "user-role", "--dept-id", "123"},
|
||||
{"class", "search-by-name", "--query-type", "student", "--name", "张三"},
|
||||
{"class", "headmaster", "--class-name", "一年级1班"},
|
||||
{"class", "search-by-teacher", "--name", "张老师"},
|
||||
// class — update-student 各分支
|
||||
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1",
|
||||
"--student-name", "张三", "--student-number", "S1", "--append-patriarch",
|
||||
"--patriarchs", `[{"userId":"uid1","relation":"F"}]`},
|
||||
{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"},
|
||||
// class — add-student 各手机号来源分支
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--student-mobile", "13800138000", "--student-user-id", "u1",
|
||||
"--student-number", "S1", "--virtual-account-id", "v1"},
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--mother", `{"mobile":"13800138000","relation":"M"}`},
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--father", `{"mobile":"13900139000","relation":"F"}`},
|
||||
{"class", "add-student", "--dept-id", "123", "--student-name", "张三",
|
||||
"--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`},
|
||||
// class — modify-student-info 两个分支
|
||||
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"},
|
||||
{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1",
|
||||
"--patriarch-user-id", "p1", "--relation", "父亲"},
|
||||
// class — delete-teacher
|
||||
{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"},
|
||||
// class — update-info 三个可选分支
|
||||
{"class", "update-info", "--class-id", "123", "--nick", "火箭班"},
|
||||
{"class", "update-info", "--class-id", "123", "--expected-student-num", "45"},
|
||||
{"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"},
|
||||
// class — update-student-number
|
||||
{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"},
|
||||
// class — add-unofficial-student
|
||||
{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"},
|
||||
// class — delete-students
|
||||
{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"},
|
||||
// class — update-student-mobile
|
||||
{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"},
|
||||
// class — move-student
|
||||
{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"},
|
||||
// class — add-teachers 默认/班主任
|
||||
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"},
|
||||
{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"},
|
||||
// family
|
||||
{"family", "children"},
|
||||
{"family", "parents"},
|
||||
// teacher
|
||||
{"teacher", "classes"},
|
||||
{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`},
|
||||
}
|
||||
for _, args := range cases {
|
||||
root := newTestEduContactRoot()
|
||||
if err := executeCommand(root, args...); err != nil {
|
||||
t.Errorf("happy path %v returned error: %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEduContactErrorPathsRemaining covers validation branches not exercised by
|
||||
// the existing error tests, ensuring 100% changed-code coverage.
|
||||
func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
// eduRequiredIntFlag:空值 + 非整数
|
||||
{"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}},
|
||||
{"detail-missing-dept", []string{"class", "detail"}},
|
||||
{"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}},
|
||||
{"students-missing-dept", []string{"class", "students"}},
|
||||
{"teachers-missing-dept", []string{"class", "teachers"}},
|
||||
{"samename-missing-dept", []string{"class", "same-name"}},
|
||||
{"userrole-missing-dept", []string{"class", "user-role"}},
|
||||
{"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}},
|
||||
{"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}},
|
||||
{"headmaster-missing-classname", []string{"class", "headmaster"}},
|
||||
// update-student
|
||||
{"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}},
|
||||
{"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}},
|
||||
{"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}},
|
||||
// add-student
|
||||
{"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}},
|
||||
{"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}},
|
||||
{"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}},
|
||||
{"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}},
|
||||
{"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}},
|
||||
// modify-student-info
|
||||
{"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}},
|
||||
{"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}},
|
||||
// delete-teacher
|
||||
{"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}},
|
||||
// update-info
|
||||
{"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}},
|
||||
{"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}},
|
||||
// update-student-number
|
||||
{"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}},
|
||||
{"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}},
|
||||
// add-unofficial-student
|
||||
{"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}},
|
||||
{"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}},
|
||||
// delete-students
|
||||
{"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}},
|
||||
{"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}},
|
||||
// update-student-mobile
|
||||
{"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}},
|
||||
{"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}},
|
||||
// move-student
|
||||
{"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}},
|
||||
{"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}},
|
||||
// add-teachers
|
||||
{"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}},
|
||||
{"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
root := newTestEduContactRoot()
|
||||
if err := executeCommand(root, tc.args...); err == nil {
|
||||
t.Errorf("%s: expected error, got nil", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Dispatch 验证测试 — 验证命令正确派发到 MCP Server
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// withEduContactDispatchCaller installs a non-dry-run capture caller so that
|
||||
// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify
|
||||
// the dispatched productID, tool name, and args.
|
||||
func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) {
|
||||
t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "detail", "--dept-id", "123"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "get_class_detail" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "query_class_by_guardian_name" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["queryType"] != "student" {
|
||||
t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student")
|
||||
}
|
||||
if input["name"] != "张三" {
|
||||
t.Errorf("input[name] = %v, want %q", input["name"], "张三")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"school", "stats", "--statistics-type", "2"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "statistics_school" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "statistics_school")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["statisticsType"] != int64(2) {
|
||||
t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "batch_add_class_teacher" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
if input["deptId"] != int64(789) {
|
||||
t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"])
|
||||
}
|
||||
teacherUserIds, ok := input["teacherUserIds"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"])
|
||||
}
|
||||
if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" {
|
||||
t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds)
|
||||
}
|
||||
if input["isAdviser"] != int64(1) {
|
||||
t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) {
|
||||
caller := withEduContactDispatchCaller(t)
|
||||
root := newEduContactCommand()
|
||||
root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.productID, "edu-contact")
|
||||
}
|
||||
if caller.tool != "move_student" {
|
||||
t.Errorf("tool = %q, want %q", caller.tool, "move_student")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args)
|
||||
}
|
||||
studentUserIds, ok := input["studentUserIds"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"])
|
||||
}
|
||||
if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" {
|
||||
t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds)
|
||||
}
|
||||
if input["originClassId"] != int64(100) {
|
||||
t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"])
|
||||
}
|
||||
if input["targetClassId"] != int64(200) {
|
||||
t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
|
||||
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newEduContactConfirmRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newEduContactCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个
|
||||
// user_required 破坏性叶子做成对验证:
|
||||
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
|
||||
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
|
||||
func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
"class delete-teacher",
|
||||
[]string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"},
|
||||
"delete_teacher",
|
||||
map[string]any{"classId": int64(12345), "teacherUserId": "userId1"},
|
||||
},
|
||||
{
|
||||
"class delete-students",
|
||||
[]string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"},
|
||||
"delete_students",
|
||||
map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduContactConfirmRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduContactConfirmRoot()
|
||||
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "edu-contact" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws edu-familygroup — 家庭群管理
|
||||
// 共 6 个工具,按 group(读操作)/ manage(写操作)分组
|
||||
// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newEduFamilyGroupCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "edu-familygroup",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制",
|
||||
UseWhen: []string{
|
||||
"用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。",
|
||||
},
|
||||
},
|
||||
})
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "edu-familygroup",
|
||||
Short: "家庭群",
|
||||
Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`,
|
||||
Hidden: true,
|
||||
RunE: groupRunE,
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// group 子命令组 — 家庭群读操作
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
groupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE})
|
||||
|
||||
groupCheckExistsCmd := &cobra.Command{
|
||||
Use: "check-exists",
|
||||
Short: "检查家庭群是否存在",
|
||||
Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。
|
||||
仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。
|
||||
面向家长(GUARDIAN)角色。`,
|
||||
Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家"
|
||||
dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{
|
||||
"input": map[string]any{"uid": uid, "groupName": groupName},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "check_family_group_exists",
|
||||
CanonicalPath: "edu-familygroup.check_family_group_exists",
|
||||
CLIPath: "edu-familygroup group check-exists",
|
||||
PrimaryCLIPath: "edu-familygroup group check-exists",
|
||||
},
|
||||
Description: "检查家庭群是否存在",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "检查家庭群是否存在",
|
||||
UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"},
|
||||
AvoidWhen: []string{"查询家庭成员信息用 list-children"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "group-name", Property: "input.groupName", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
groupListChildrenCmd := &cobra.Command{
|
||||
Use: "list-children",
|
||||
Short: "查询家长绑定的孩子列表",
|
||||
Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织),
|
||||
包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。
|
||||
面向家长(GUARDIAN)角色。`,
|
||||
Example: ` dws edu-familygroup group list-children --uid 12345
|
||||
dws edu-familygroup group list-children --uid 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{
|
||||
"input": map[string]any{"uid": uid},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "listBoundChildren",
|
||||
CanonicalPath: "edu-familygroup.listBoundChildren",
|
||||
CLIPath: "edu-familygroup group list-children",
|
||||
PrimaryCLIPath: "edu-familygroup group list-children",
|
||||
},
|
||||
Description: "查询家长绑定的孩子列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询家长绑定的孩子列表",
|
||||
UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"},
|
||||
AvoidWhen: []string{"查看家庭群是否存在用 check-exists"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup group list-children --uid 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// manage 子命令组 — 家庭群写操作
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
manageCmd := newGroupCommand(&cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE})
|
||||
|
||||
manageCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建家庭群",
|
||||
Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。
|
||||
children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、
|
||||
birthday / gender / nick / avatar / period / grade / mobile(均可选)。
|
||||
addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。
|
||||
新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`,
|
||||
Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]'
|
||||
dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childrenRaw, _ := cmd.Flags().GetString("children")
|
||||
if strings.TrimSpace(childrenRaw) == "" {
|
||||
return fmt.Errorf("--children 为必填参数")
|
||||
}
|
||||
var children []any
|
||||
if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil {
|
||||
return fmt.Errorf("--children 须为合法 JSON 数组: %w", err)
|
||||
}
|
||||
if err := eduFamilyGroupValidateChildren(children); err != nil {
|
||||
return err
|
||||
}
|
||||
input := map[string]any{"uid": uid, "children": children}
|
||||
if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" {
|
||||
var addGroup map[string]any
|
||||
if err := json.Unmarshal([]byte(v), &addGroup); err != nil {
|
||||
return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err)
|
||||
}
|
||||
if addGroup == nil {
|
||||
return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null")
|
||||
}
|
||||
input["addGroup"] = addGroup
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") {
|
||||
input["source"] = v
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{
|
||||
"input": input,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageCreateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "create_family_group",
|
||||
CanonicalPath: "edu-familygroup.create_family_group",
|
||||
CLIPath: "edu-familygroup manage create",
|
||||
PrimaryCLIPath: "edu-familygroup manage create",
|
||||
},
|
||||
Description: "创建家庭群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建家庭群并同步创建家庭组织",
|
||||
UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"},
|
||||
AvoidWhen: []string{"已有家庭群要加孩子用 add-child"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "children", Property: "input.children", Required: boolPtr(true)},
|
||||
{Name: "add-group", Property: "input.addGroup"},
|
||||
{Name: "source", Property: "input.source"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
manageInviteParentCmd := &cobra.Command{
|
||||
Use: "invite-parent",
|
||||
Short: "短信邀请家长加入家庭群",
|
||||
Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。
|
||||
返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`,
|
||||
Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000
|
||||
dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{
|
||||
"input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "invite_parent_to_familygroup",
|
||||
CanonicalPath: "edu-familygroup.invite_parent_to_familygroup",
|
||||
CLIPath: "edu-familygroup manage invite-parent",
|
||||
PrimaryCLIPath: "edu-familygroup manage invite-parent",
|
||||
},
|
||||
Description: "短信邀请家长加入家庭群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "通过短信邀请家长加入家庭群",
|
||||
UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"},
|
||||
AvoidWhen: []string{"添加孩子到家庭群用 add-child"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "mobile", Property: "input.mobile", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
manageAddChildCmd := &cobra.Command{
|
||||
Use: "add-child",
|
||||
Short: "为家庭群添加孩子",
|
||||
Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由):
|
||||
- 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群
|
||||
- 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系
|
||||
- 同时传 --mobile + --students:mobile 优先,走手机号邀请链路
|
||||
mobile 与 students 至少传一个。
|
||||
students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。
|
||||
仅家长(GUARDIAN)角色可调用。`,
|
||||
Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000
|
||||
dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := eduFamilyGroupRequiredStringFlag(cmd, "name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mobile, _ := cmd.Flags().GetString("mobile")
|
||||
mobile = strings.TrimSpace(mobile)
|
||||
studentsRaw, _ := cmd.Flags().GetString("students")
|
||||
studentsRaw = strings.TrimSpace(studentsRaw)
|
||||
if mobile == "" && studentsRaw == "" {
|
||||
return fmt.Errorf("--mobile 与 --students 至少传一个")
|
||||
}
|
||||
input := map[string]any{"orgId": orgID, "uid": uid, "name": name}
|
||||
if mobile != "" {
|
||||
input["mobile"] = mobile
|
||||
}
|
||||
if studentsRaw != "" {
|
||||
var students []any
|
||||
if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil {
|
||||
return fmt.Errorf("--students 须为合法 JSON 数组: %w", err)
|
||||
}
|
||||
if err := eduFamilyGroupValidateStudents(students); err != nil {
|
||||
return err
|
||||
}
|
||||
input["students"] = students
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{
|
||||
"input": input,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageAddChildCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "add_child_to_family_group",
|
||||
CanonicalPath: "edu-familygroup.add_child_to_family_group",
|
||||
CLIPath: "edu-familygroup manage add-child",
|
||||
PrimaryCLIPath: "edu-familygroup manage add-child",
|
||||
},
|
||||
Description: "为家庭群添加孩子",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为已有家庭群添加孩子",
|
||||
UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"},
|
||||
AvoidWhen: []string{"创建全新家庭群用 create"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "input.name", Required: boolPtr(true)},
|
||||
{Name: "mobile", Property: "input.mobile"},
|
||||
{Name: "students", Property: "input.students"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
manageToggleAppCmd := &cobra.Command{
|
||||
Use: "toggle-app",
|
||||
Short: "开启或关闭学生应用权限",
|
||||
Long: `为指定学生号开启或关闭应用权限。
|
||||
支持的应用类型(--app-type):
|
||||
- XIAOTIANDI:小天地(学生圈)
|
||||
- LEARNING_VIDEO:学习视频
|
||||
直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`,
|
||||
Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true
|
||||
dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" {
|
||||
return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO")
|
||||
}
|
||||
openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var open bool
|
||||
switch strings.ToLower(openStr) {
|
||||
case "true":
|
||||
open = true
|
||||
case "false":
|
||||
open = false
|
||||
default:
|
||||
return fmt.Errorf("--open 须为 true 或 false")
|
||||
}
|
||||
return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{
|
||||
"input": map[string]any{
|
||||
"orgId": orgID, "uid": uid, "childStaffId": childStaffID,
|
||||
"appType": appType, "open": open,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-familygroup",
|
||||
Name: "toggle_student_app",
|
||||
CanonicalPath: "edu-familygroup.toggle_student_app",
|
||||
CLIPath: "edu-familygroup manage toggle-app",
|
||||
PrimaryCLIPath: "edu-familygroup manage toggle-app",
|
||||
},
|
||||
Description: "开启或关闭学生应用权限",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "开启或关闭学生应用权限",
|
||||
UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"},
|
||||
AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"},
|
||||
Examples: []string{
|
||||
"dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "org-id", Property: "input.orgId", Required: boolPtr(true)},
|
||||
{Name: "uid", Property: "input.uid", Required: boolPtr(true)},
|
||||
{Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)},
|
||||
{Name: "app-type", Property: "input.appType", Required: boolPtr(true)},
|
||||
{Name: "open", Property: "input.open", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// flags + 构建命令树
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// group(读操作)flags
|
||||
groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)")
|
||||
groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)")
|
||||
groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)")
|
||||
|
||||
// manage(写操作)flags
|
||||
manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)")
|
||||
manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)")
|
||||
manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)")
|
||||
manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)")
|
||||
|
||||
manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
|
||||
manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)")
|
||||
manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)")
|
||||
|
||||
manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
|
||||
manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)")
|
||||
manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)")
|
||||
manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)")
|
||||
manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)")
|
||||
|
||||
manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)")
|
||||
manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)")
|
||||
manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)")
|
||||
manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)")
|
||||
manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)")
|
||||
|
||||
groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd)
|
||||
manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd)
|
||||
|
||||
root.AddCommand(groupCmd, manageCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// eduFamilyGroupValidateChildren validates the --children payload: the array
|
||||
// must be non-empty, each child must carry a non-empty name and a non-empty
|
||||
// students array, and each student must carry corpId + staffId.
|
||||
func eduFamilyGroupValidateChildren(children []any) error {
|
||||
if len(children) == 0 {
|
||||
return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子")
|
||||
}
|
||||
for i, c := range children {
|
||||
child, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("--children[%d] 须为 JSON 对象", i)
|
||||
}
|
||||
name, _ := child["name"].(string)
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("--children[%d].name 为必填字段", i)
|
||||
}
|
||||
students, ok := child["students"].([]any)
|
||||
if !ok || len(students) == 0 {
|
||||
return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i)
|
||||
}
|
||||
for j, s := range students {
|
||||
student, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j)
|
||||
}
|
||||
corpID, _ := student["corpId"].(string)
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j)
|
||||
}
|
||||
staffID, _ := student["staffId"].(string)
|
||||
if strings.TrimSpace(staffID) == "" {
|
||||
return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// eduFamilyGroupValidateStudents validates the --students payload: the array
|
||||
// must be non-empty and each element must carry a numeric schoolOrgId and a
|
||||
// non-empty studentStaffId.
|
||||
func eduFamilyGroupValidateStudents(students []any) error {
|
||||
if len(students) == 0 {
|
||||
return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号")
|
||||
}
|
||||
for i, s := range students {
|
||||
student, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("--students[%d] 须为 JSON 对象", i)
|
||||
}
|
||||
switch student["schoolOrgId"].(type) {
|
||||
case float64, int, int64, json.Number:
|
||||
default:
|
||||
return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i)
|
||||
}
|
||||
staffID, _ := student["studentStaffId"].(string)
|
||||
if strings.TrimSpace(staffID) == "" {
|
||||
return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an
|
||||
// error if the flag is empty or not a valid integer.
|
||||
func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, fmt.Errorf("--%s 为必填参数", name)
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// eduFamilyGroupRequiredStringFlag extracts a required string flag.
|
||||
func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("--%s 为必填参数", name)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"group", "check-exists", "--uid", "1", "--group-name", "小明一家"},
|
||||
{"group", "list-children", "--uid", "1"},
|
||||
{"manage", "create", "--uid", "1",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
|
||||
"--add-group", `{"schoolCorpId":"x"}`, "--source", "1"},
|
||||
{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"},
|
||||
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"},
|
||||
{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明",
|
||||
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`},
|
||||
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
|
||||
"--app-type", "XIAOTIANDI", "--open", "true"},
|
||||
{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s",
|
||||
"--app-type", "LEARNING_VIDEO", "--open", "false"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduFamilyGroupCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"},
|
||||
{"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"},
|
||||
{"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"},
|
||||
{"list-children missing uid", []string{"group", "list-children"}, "uid"},
|
||||
{"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"},
|
||||
{"create missing children", []string{"manage", "create", "--uid", "1"}, "children"},
|
||||
{"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"},
|
||||
{"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"},
|
||||
{"create add-group bad json", []string{"manage", "create", "--uid", "1",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
|
||||
"--add-group", "{"}, "add-group"},
|
||||
{"create add-group null", []string{"manage", "create", "--uid", "1",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`,
|
||||
"--add-group", "null"}, "null"},
|
||||
{"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"},
|
||||
{"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"},
|
||||
{"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"},
|
||||
{"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"},
|
||||
{"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"},
|
||||
{"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"},
|
||||
{"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"},
|
||||
{"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"},
|
||||
{"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"},
|
||||
{"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"},
|
||||
{"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"},
|
||||
{"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"},
|
||||
{"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"},
|
||||
{"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"},
|
||||
{"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"},
|
||||
{"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
withEduFamilyGroupCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs(tc.args)
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) {
|
||||
valid := []any{map[string]any{
|
||||
"name": "小明",
|
||||
"students": []any{map[string]any{"corpId": "c", "staffId": "s"}},
|
||||
}}
|
||||
if err := eduFamilyGroupValidateChildren(valid); err != nil {
|
||||
t.Fatalf("valid children error = %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
children []any
|
||||
want string
|
||||
}{
|
||||
{"empty", []any{}, "不能为空数组"},
|
||||
{"non-object", []any{1}, "须为 JSON 对象"},
|
||||
{"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"},
|
||||
{"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"},
|
||||
{"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"},
|
||||
{"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"},
|
||||
{"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) {
|
||||
valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}}
|
||||
if err := eduFamilyGroupValidateStudents(valid); err != nil {
|
||||
t.Fatalf("valid students error = %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
students []any
|
||||
want string
|
||||
}{
|
||||
{"empty", []any{}, "不能为空数组"},
|
||||
{"non-object", []any{1}, "须为 JSON 对象"},
|
||||
{"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"},
|
||||
{"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) {
|
||||
t.Run("check-exists", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "check_family_group_exists" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["uid"] != int64(123) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"])
|
||||
}
|
||||
if input["groupName"] != "测试家庭" {
|
||||
t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list-children", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"group", "list-children", "--uid", "456"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "listBoundChildren" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["uid"] != int64(456) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "create", "--uid", "789",
|
||||
"--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "create_family_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["uid"] != int64(789) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"])
|
||||
}
|
||||
if input["children"] == nil {
|
||||
t.Fatalf("input[\"children\"] is nil, want non-nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invite-parent", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "invite_parent_to_familygroup" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["orgId"] != int64(1) {
|
||||
t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"])
|
||||
}
|
||||
if input["uid"] != int64(2) {
|
||||
t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"])
|
||||
}
|
||||
if input["mobile"] != "13800138000" {
|
||||
t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("toggle-app", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2",
|
||||
"--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "toggle_student_app" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"])
|
||||
}
|
||||
if input["appType"] != "XIAOTIANDI" {
|
||||
t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI")
|
||||
}
|
||||
if input["open"] != true {
|
||||
t.Fatalf("input[\"open\"] = %#v, want true", input["open"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add-child", func(t *testing.T) {
|
||||
caller := withEduFamilyGroupDispatchCaller(t)
|
||||
cmd := newEduFamilyGroupCommand()
|
||||
cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890",
|
||||
"--name", "小明", "--mobile", "13900139000",
|
||||
"--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-familygroup" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup")
|
||||
}
|
||||
if caller.tool != "add_child_to_family_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group")
|
||||
}
|
||||
want := map[string]any{
|
||||
"input": map[string]any{
|
||||
"orgId": int64(12345),
|
||||
"uid": int64(67890),
|
||||
"name": "小明",
|
||||
"mobile": "13900139000",
|
||||
"students": []any{
|
||||
map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", caller.args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,846 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws edu-group — 家校群管理
|
||||
// 共 14 个工具,按 student-group / class-group / batch 分组
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newEduGroupCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "edu-group",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作",
|
||||
UseWhen: []string{
|
||||
"用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。",
|
||||
},
|
||||
},
|
||||
})
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "edu-group",
|
||||
Short: "家校群",
|
||||
Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`,
|
||||
Hidden: true,
|
||||
RunE: groupRunE,
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// student-group 子命令组 — 师生群管理
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
studentGroupCmd := newGroupCommand(&cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE})
|
||||
|
||||
studentGroupInfoCmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "查询班级师生群信息",
|
||||
Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group info --dept-id 12345
|
||||
dws edu-group student-group info --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_class_group_info",
|
||||
CanonicalPath: "edu-group.get_class_group_info",
|
||||
CLIPath: "edu-group student-group info",
|
||||
PrimaryCLIPath: "edu-group student-group info",
|
||||
},
|
||||
Description: "查询班级师生群信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询班级师生群信息",
|
||||
UseWhen: []string{"需要查询指定班级的师生群会话ID时"},
|
||||
AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group info --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupExistsCmd := &cobra.Command{
|
||||
Use: "exists",
|
||||
Short: "检查组织是否已创建师生群",
|
||||
Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group student-group exists --dept-id 12345
|
||||
dws edu-group student-group exists --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "check_class_group_exists",
|
||||
CanonicalPath: "edu-group.check_class_group_exists",
|
||||
CLIPath: "edu-group student-group exists",
|
||||
PrimaryCLIPath: "edu-group student-group exists",
|
||||
},
|
||||
Description: "检查组织是否已创建师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "检查组织是否已创建师生群",
|
||||
UseWhen: []string{"需要判断指定班级是否已创建师生群时"},
|
||||
AvoidWhen: []string{"查询师生群成员用 student-group members"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group exists --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupMembersCmd := &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "查询师生群成员列表",
|
||||
Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group members --dept-id 12345
|
||||
dws edu-group student-group members --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_group_members",
|
||||
CanonicalPath: "edu-group.get_group_members",
|
||||
CLIPath: "edu-group student-group members",
|
||||
PrimaryCLIPath: "edu-group student-group members",
|
||||
},
|
||||
Description: "查询师生群成员列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询师生群成员列表",
|
||||
UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"},
|
||||
AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group members --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupIsInCmd := &cobra.Command{
|
||||
Use: "is-in",
|
||||
Short: "判断用户是否在师生群中",
|
||||
Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group is-in --dept-id 12345
|
||||
dws edu-group student-group is-in --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "is_in_class_group",
|
||||
CanonicalPath: "edu-group.is_in_class_group",
|
||||
CLIPath: "edu-group student-group is-in",
|
||||
PrimaryCLIPath: "edu-group student-group is-in",
|
||||
},
|
||||
Description: "判断用户是否在师生群中",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "判断用户是否在师生群中",
|
||||
UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"},
|
||||
AvoidWhen: []string{"查询群成员列表用 student-group members"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group is-in --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupConversationCmd := &cobra.Command{
|
||||
Use: "conversation",
|
||||
Short: "查询班级群会话详情",
|
||||
Long: `查询指定班级师生群的会话详情。
|
||||
返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
|
||||
管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group student-group conversation --dept-id 12345
|
||||
dws edu-group student-group conversation --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_group_conversation_info",
|
||||
CanonicalPath: "edu-group.get_group_conversation_info",
|
||||
CLIPath: "edu-group student-group conversation",
|
||||
PrimaryCLIPath: "edu-group student-group conversation",
|
||||
},
|
||||
Description: "查询班级群会话详情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询班级师生群会话详情",
|
||||
UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"},
|
||||
AvoidWhen: []string{"仅需群会话ID用 student-group info"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group conversation --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建班级师生群",
|
||||
Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。
|
||||
前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`,
|
||||
Example: ` dws edu-group student-group create --dept-id 12345
|
||||
dws edu-group student-group create --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "create_class_group",
|
||||
CanonicalPath: "edu-group.create_class_group",
|
||||
CLIPath: "edu-group student-group create",
|
||||
PrimaryCLIPath: "edu-group student-group create",
|
||||
},
|
||||
Description: "创建班级师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定班级创建师生群",
|
||||
UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"},
|
||||
AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group create --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
studentGroupDisbandCmd := &cobra.Command{
|
||||
Use: "disband",
|
||||
Short: "解散班级师生群",
|
||||
Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`,
|
||||
Example: ` dws edu-group student-group disband --dept-id 12345
|
||||
dws edu-group student-group disband --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "disband_class_group",
|
||||
CanonicalPath: "edu-group.disband_class_group",
|
||||
CLIPath: "edu-group student-group disband",
|
||||
PrimaryCLIPath: "edu-group student-group disband",
|
||||
},
|
||||
Description: "解散班级师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "解散班级师生群并删除关联关系",
|
||||
UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"},
|
||||
AvoidWhen: []string{"查询师生群信息用 student-group info"},
|
||||
Examples: []string{
|
||||
"dws edu-group student-group disband --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// class-group 子命令组 — 班级群(家校群)会话管理
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
classGroupCmd := newGroupCommand(&cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE})
|
||||
|
||||
classGroupConversationIDCmd := &cobra.Command{
|
||||
Use: "conversation-id",
|
||||
Short: "获取班级群会话ID",
|
||||
Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group conversation-id --dept-id 12345
|
||||
dws edu-group class-group conversation-id --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_class_conversation_id",
|
||||
CanonicalPath: "edu-group.get_class_conversation_id",
|
||||
CLIPath: "edu-group class-group conversation-id",
|
||||
PrimaryCLIPath: "edu-group class-group conversation-id",
|
||||
},
|
||||
Description: "获取班级群会话ID",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取班级群会话ID",
|
||||
UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"},
|
||||
AvoidWhen: []string{"需要完整群信息用 class-group conversation"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group conversation-id --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
classGroupConversationCmd := &cobra.Command{
|
||||
Use: "conversation",
|
||||
Short: "获取班级群完整会话信息",
|
||||
Long: `获取指定班级的班级群完整会话信息。
|
||||
返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
|
||||
管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group conversation --dept-id 12345
|
||||
dws edu-group class-group conversation --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "get_class_conversation",
|
||||
CanonicalPath: "edu-group.get_class_conversation",
|
||||
CLIPath: "edu-group class-group conversation",
|
||||
PrimaryCLIPath: "edu-group class-group conversation",
|
||||
},
|
||||
Description: "获取班级群完整会话信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取班级群完整会话信息",
|
||||
UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"},
|
||||
AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group conversation --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
classGroupExistsCmd := &cobra.Command{
|
||||
Use: "exists",
|
||||
Short: "检查班级群是否存在",
|
||||
Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group exists --dept-id 12345
|
||||
dws edu-group class-group exists --dept-id 12345 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{
|
||||
"input": map[string]any{"deptId": deptID},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "check_class_conversation_exists",
|
||||
CanonicalPath: "edu-group.check_class_conversation_exists",
|
||||
CLIPath: "edu-group class-group exists",
|
||||
PrimaryCLIPath: "edu-group class-group exists",
|
||||
},
|
||||
Description: "检查班级群是否存在",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "检查班级群是否存在",
|
||||
UseWhen: []string{"需要判断指定班级是否已创建班级群时"},
|
||||
AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group exists --dept-id 12345 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
classGroupListByConversationIDsCmd := &cobra.Command{
|
||||
Use: "list-by-cids",
|
||||
Short: "根据会话ID列表批量查询群信息",
|
||||
Long: `根据群会话ID列表批量查询群的详细信息。
|
||||
返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。
|
||||
管理员、班主任、老师角色可调用。`,
|
||||
Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3
|
||||
dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, _ := cmd.Flags().GetString("conversation-ids")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("--conversation-ids 为必填参数")
|
||||
}
|
||||
conversationIDs := eduGroupParseCSV(raw)
|
||||
if len(conversationIDs) == 0 {
|
||||
return fmt.Errorf("--conversation-ids 不能为空")
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{
|
||||
"input": map[string]any{"conversationIds": conversationIDs},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "list_groups_by_conversation_ids",
|
||||
CanonicalPath: "edu-group.list_groups_by_conversation_ids",
|
||||
CLIPath: "edu-group class-group list-by-cids",
|
||||
PrimaryCLIPath: "edu-group class-group list-by-cids",
|
||||
},
|
||||
Description: "根据会话ID列表批量查询群信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "根据会话ID列表批量查询群信息",
|
||||
UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"},
|
||||
AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"},
|
||||
Examples: []string{
|
||||
"dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// batch 子命令组 — 批量操作
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
batchCmd := newGroupCommand(&cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE})
|
||||
|
||||
batchCheckClassGroupCmd := &cobra.Command{
|
||||
Use: "check-student-group",
|
||||
Short: "批量检查班级是否已创建师生群",
|
||||
Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group batch check-student-group --class-ids 12345,67890
|
||||
dws edu-group batch check-student-group --class-ids 12345,67890 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, _ := cmd.Flags().GetString("class-ids")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("--class-ids 为必填参数")
|
||||
}
|
||||
classIDs, err := eduGroupParseIntCSV(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err)
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{
|
||||
"input": map[string]any{"classIds": classIDs},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "batch_check_class_group",
|
||||
CanonicalPath: "edu-group.batch_check_class_group",
|
||||
CLIPath: "edu-group batch check-student-group",
|
||||
PrimaryCLIPath: "edu-group batch check-student-group",
|
||||
},
|
||||
Description: "批量检查班级是否已创建师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量检查班级是否已创建师生群",
|
||||
UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"},
|
||||
AvoidWhen: []string{"单个班级检查用 student-group exists"},
|
||||
Examples: []string{
|
||||
"dws edu-group batch check-student-group --class-ids 12345,67890 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
batchGetClassConversationsCmd := &cobra.Command{
|
||||
Use: "get-class-groups",
|
||||
Short: "批量获取班级群信息",
|
||||
Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890
|
||||
dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
raw, _ := cmd.Flags().GetString("class-ids")
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return fmt.Errorf("--class-ids 为必填参数")
|
||||
}
|
||||
classIDs := eduGroupParseCSV(raw)
|
||||
if len(classIDs) == 0 {
|
||||
return fmt.Errorf("--class-ids 不能为空")
|
||||
}
|
||||
return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{
|
||||
"input": map[string]any{"classIds": classIDs},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "batch_get_class_conversations",
|
||||
CanonicalPath: "edu-group.batch_get_class_conversations",
|
||||
CLIPath: "edu-group batch get-class-groups",
|
||||
PrimaryCLIPath: "edu-group batch get-class-groups",
|
||||
},
|
||||
Description: "批量获取班级群信息",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量获取班级群会话信息",
|
||||
UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"},
|
||||
AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"},
|
||||
Examples: []string{
|
||||
"dws edu-group batch get-class-groups --class-ids 12345,67890 --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
batchCreateClassGroupCmd := &cobra.Command{
|
||||
Use: "create-student-groups",
|
||||
Short: "批量创建师生群",
|
||||
Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。
|
||||
小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`,
|
||||
Example: ` dws edu-group batch create-student-groups
|
||||
dws edu-group batch create-student-groups -f json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{
|
||||
"input": map[string]any{},
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "edu-group",
|
||||
Name: "batch_create_class_group",
|
||||
CanonicalPath: "edu-group.batch_create_class_group",
|
||||
CLIPath: "edu-group batch create-student-groups",
|
||||
PrimaryCLIPath: "edu-group batch create-student-groups",
|
||||
},
|
||||
Description: "批量创建师生群",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为组织下符合条件的班级批量创建师生群",
|
||||
UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"},
|
||||
AvoidWhen: []string{"单个班级创建用 student-group create"},
|
||||
Examples: []string{
|
||||
"dws edu-group batch create-student-groups --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{},
|
||||
},
|
||||
})
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// flags + 构建命令树
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// student-group flags
|
||||
studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
|
||||
// class-group flags
|
||||
classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)")
|
||||
classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)")
|
||||
|
||||
// batch flags
|
||||
batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
|
||||
batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)")
|
||||
|
||||
studentGroupCmd.AddCommand(
|
||||
studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd,
|
||||
studentGroupIsInCmd, studentGroupConversationCmd,
|
||||
studentGroupCreateCmd, studentGroupDisbandCmd,
|
||||
)
|
||||
classGroupCmd.AddCommand(
|
||||
classGroupConversationIDCmd, classGroupConversationCmd,
|
||||
classGroupExistsCmd, classGroupListByConversationIDsCmd,
|
||||
)
|
||||
batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd)
|
||||
|
||||
root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// eduGroupRequiredIntFlag extracts a required integer flag, returning an error
|
||||
// if the flag is empty or not a valid integer.
|
||||
func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) {
|
||||
v, _ := cmd.Flags().GetString(name)
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, fmt.Errorf("--%s 为必填参数", name)
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("--%s 须为整数: %w", name, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values.
|
||||
func eduGroupParseCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v != "" {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// eduGroupParseIntCSV splits a comma-separated string into int64 values.
|
||||
func eduGroupParseIntCSV(raw string) ([]int64, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
result := make([]int64, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
v := strings.TrimSpace(p)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer %q", v)
|
||||
}
|
||||
result = append(result, n)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// withEduGroupCaller installs a dry-run capture caller so happy-path command
|
||||
// execution exercises each RunE up to the callMCPToolOnServer dispatch without
|
||||
// requiring a live MCP transport.
|
||||
func withEduGroupCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"student-group", "info", "--dept-id", "123"},
|
||||
{"student-group", "exists", "--dept-id", "123"},
|
||||
{"student-group", "members", "--dept-id", "123"},
|
||||
{"student-group", "is-in", "--dept-id", "123"},
|
||||
{"student-group", "conversation", "--dept-id", "123"},
|
||||
{"student-group", "create", "--dept-id", "123"},
|
||||
{"student-group", "disband", "--dept-id", "123"},
|
||||
{"class-group", "conversation-id", "--dept-id", "123"},
|
||||
{"class-group", "conversation", "--dept-id", "123"},
|
||||
{"class-group", "exists", "--dept-id", "123"},
|
||||
{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"},
|
||||
{"batch", "check-student-group", "--class-ids", "1,2"},
|
||||
{"batch", "get-class-groups", "--class-ids", "1,2"},
|
||||
{"batch", "create-student-groups"},
|
||||
}
|
||||
for _, args := range cases {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute(%v) = %v, want nil", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) {
|
||||
// Each dept-id command must reject an absent --dept-id, covering its own
|
||||
// error branch as well as the shared eduGroupRequiredIntFlag empty case.
|
||||
deptIDCommands := [][]string{
|
||||
{"student-group", "info"},
|
||||
{"student-group", "exists"},
|
||||
{"student-group", "members"},
|
||||
{"student-group", "is-in"},
|
||||
{"student-group", "conversation"},
|
||||
{"student-group", "create"},
|
||||
{"student-group", "disband"},
|
||||
{"class-group", "conversation-id"},
|
||||
{"class-group", "conversation"},
|
||||
{"class-group", "exists"},
|
||||
}
|
||||
for _, args := range deptIDCommands {
|
||||
t.Run(strings.Join(args, " "), func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") {
|
||||
t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) {
|
||||
t.Run("non-integer dept-id", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
|
||||
t.Fatalf("non-integer dept-id error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list-by-cids missing conversation-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"class-group", "list-by-cids"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
|
||||
t.Fatalf("missing conversation-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list-by-cids only separators", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") {
|
||||
t.Fatalf("empty conversation-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch check missing class-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "check-student-group"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
|
||||
t.Fatalf("missing class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch check invalid integer class-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") {
|
||||
t.Fatalf("invalid integer class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch get missing class-ids", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "get-class-groups"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
|
||||
t.Fatalf("missing class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch get only separators", func(t *testing.T) {
|
||||
withEduGroupCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") {
|
||||
t.Fatalf("empty class-ids error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) {
|
||||
if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("eduGroupParseCSV = %#v", got)
|
||||
}
|
||||
ids, err := eduGroupParseIntCSV(" 1 , , 2 ")
|
||||
if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
|
||||
t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err)
|
||||
}
|
||||
if _, err := eduGroupParseIntCSV("1,bad"); err == nil {
|
||||
t.Fatalf("eduGroupParseIntCSV invalid = nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands
|
||||
// exercise the full dispatch path through deps.Caller.CallTool.
|
||||
func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller {
|
||||
t.Helper()
|
||||
caller := &recruitCaptureCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
return caller
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) {
|
||||
t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "get_class_group_info" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
if input["deptId"] != int64(123) {
|
||||
t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("student-group create dispatches create_class_group", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "create_class_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
if input["deptId"] != int64(456) {
|
||||
t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.PersistentFlags().Bool("yes", false, "")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "")
|
||||
cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "disband_class_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
if input["deptId"] != int64(789) {
|
||||
t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "list_groups_by_conversation_ids" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
cids, ok := input["conversationIds"].([]string)
|
||||
if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" {
|
||||
t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) {
|
||||
caller := withEduGroupDispatchCaller(t)
|
||||
cmd := newEduGroupCommand()
|
||||
cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute error = %v", err)
|
||||
}
|
||||
if caller.productID != "edu-group" {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, "edu-group")
|
||||
}
|
||||
if caller.tool != "batch_check_class_group" {
|
||||
t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group")
|
||||
}
|
||||
input, ok := caller.args["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"])
|
||||
}
|
||||
classIDs, ok := input["classIds"].([]int64)
|
||||
if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 {
|
||||
t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册
|
||||
// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。
|
||||
func newEduGroupConfirmRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示")
|
||||
root.AddCommand(newEduGroupCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个
|
||||
// user_required 破坏性叶子做成对验证:
|
||||
// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。
|
||||
// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。
|
||||
func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
wantInput map[string]any
|
||||
}{
|
||||
{
|
||||
"student-group disband",
|
||||
[]string{"edu-group", "student-group", "disband", "--dept-id", "12345"},
|
||||
"disband_class_group",
|
||||
map[string]any{"deptId": int64(12345)},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduGroupConfirmRoot()
|
||||
root.SetArgs(tc.args)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirm-gate error without --yes, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "需要用户确认") {
|
||||
t.Fatalf("expected confirmation gate error, got: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) {
|
||||
caller := &recruitCaptureCaller{dryRun: false}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newEduGroupConfirmRoot()
|
||||
root.SetArgs(append(append([]string{}, tc.args...), "--yes"))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("Execute() with --yes error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls))
|
||||
}
|
||||
if caller.calls[0].productID != "edu-group" {
|
||||
t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group")
|
||||
}
|
||||
if caller.calls[0].tool != tc.wantTool {
|
||||
t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool)
|
||||
}
|
||||
gotArgs := caller.calls[0].args
|
||||
if len(gotArgs) != 1 {
|
||||
t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs)
|
||||
}
|
||||
gotInput, ok := gotArgs["input"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"])
|
||||
}
|
||||
if !reflect.DeepEqual(gotInput, tc.wantInput) {
|
||||
t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// DefaultMarkdownChunkRunes lives in doc_write_pipeline.go and is the shared
|
||||
// limit for every chunked markdown write path.
|
||||
|
||||
// MarkdownChunkPlan is the result of splitting markdown for append-mode writes.
|
||||
//
|
||||
// The contract is deliberately NOT strings.Join(Chunks, "") == content: the
|
||||
// server's mode=append inserts a brand new structure per call, so keeping a
|
||||
// table or a fenced code block intact across a boundary requires re-emitting its
|
||||
// header or its fence. Instead:
|
||||
//
|
||||
// - len(Chunks) >= 1 always, so Chunks[0] is safe to index.
|
||||
// - every chunk is at most Limit runes when Limit > 0.
|
||||
// - every chunk is a complete, self-contained top-level block sequence: no
|
||||
// half table, no unclosed fence, and no partial line unless a hard-split
|
||||
// degradation is recorded for that boundary.
|
||||
// - every boundary that changes the rendered structure appears in
|
||||
// Degradations. An empty Degradations means ExpectedDocument() renders
|
||||
// identically to the input.
|
||||
type MarkdownChunkPlan struct {
|
||||
Chunks []string `json:"-"`
|
||||
Limit int `json:"limit"`
|
||||
Degradations []MarkdownDegradation `json:"degradations,omitempty"`
|
||||
}
|
||||
|
||||
// MarkdownDegradation records one boundary that could not be made invisible.
|
||||
type MarkdownDegradation struct {
|
||||
Kind string `json:"kind"`
|
||||
Tier string `json:"tier"`
|
||||
// ChunkIndex is the chunk *before* the boundary this describes.
|
||||
ChunkIndex int `json:"chunkIndex"`
|
||||
Line int `json:"line"`
|
||||
Detail string `json:"detail"`
|
||||
// InjectedSuffix was appended to Chunks[ChunkIndex] and InjectedPrefix was
|
||||
// prepended to Chunks[ChunkIndex+1] — the two halves of one repair, which
|
||||
// live in different chunks and are therefore reported separately. They exist
|
||||
// so a caller can strip them and recover the original content exactly.
|
||||
InjectedSuffix string `json:"injectedSuffix,omitempty"`
|
||||
InjectedPrefix string `json:"injectedPrefix,omitempty"`
|
||||
}
|
||||
|
||||
// Degraded reports whether any boundary changed the rendered structure.
|
||||
func (p MarkdownChunkPlan) Degraded() bool { return len(p.Degradations) > 0 }
|
||||
|
||||
// ExpectedDocument is the document the server is expected to hold once every
|
||||
// chunk has been appended. Readback verification must compare against this
|
||||
// rather than the original content, because repaired boundaries legitimately
|
||||
// differ from the input.
|
||||
//
|
||||
// It is a method rather than a field so a large document is not duplicated in
|
||||
// memory unless a caller actually verifies.
|
||||
func (p MarkdownChunkPlan) ExpectedDocument() string {
|
||||
if len(p.Chunks) == 1 {
|
||||
return p.Chunks[0]
|
||||
}
|
||||
return strings.Join(p.Chunks, "\n\n")
|
||||
}
|
||||
|
||||
// Warnings renders one human-readable line per distinct degradation kind,
|
||||
// aggregated with a count and the affected line numbers.
|
||||
func (p MarkdownChunkPlan) Warnings() []string {
|
||||
if len(p.Degradations) == 0 {
|
||||
return nil
|
||||
}
|
||||
order := make([]string, 0, len(p.Degradations))
|
||||
detail := map[string]string{}
|
||||
lines := map[string][]int{}
|
||||
for _, d := range p.Degradations {
|
||||
if _, seen := detail[d.Kind]; !seen {
|
||||
order = append(order, d.Kind)
|
||||
detail[d.Kind] = d.Detail
|
||||
}
|
||||
lines[d.Kind] = append(lines[d.Kind], d.Line)
|
||||
}
|
||||
out := make([]string, 0, len(order))
|
||||
for _, kind := range order {
|
||||
at := lines[kind]
|
||||
if len(at) == 1 {
|
||||
out = append(out, fmt.Sprintf("内容过长已分片:%s(第 %d 行)", detail[kind], at[0]))
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("内容过长已分片:%s(%d 处,首次在第 %d 行)",
|
||||
detail[kind], len(at), at[0]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Summary is the structured projection for command envelopes.
|
||||
func (p MarkdownChunkPlan) Summary() map[string]any {
|
||||
return map[string]any{
|
||||
"chunks": len(p.Chunks),
|
||||
"limit": p.Limit,
|
||||
"degraded": p.Degraded(),
|
||||
"degradations": p.Degradations,
|
||||
}
|
||||
}
|
||||
|
||||
// SplitMarkdownForAppend splits content into chunks that are each safe to send
|
||||
// as an independent update_document mode=append call. See MarkdownChunkPlan for
|
||||
// the exact contract.
|
||||
//
|
||||
// limitRunes <= 0 disables splitting and returns the content as a single chunk,
|
||||
// which callers use as an explicit "send it in one call whatever the size"
|
||||
// escape hatch.
|
||||
func SplitMarkdownForAppend(content string, limitRunes int) MarkdownChunkPlan {
|
||||
normalized := normalizeMarkdownNewlines(content)
|
||||
plan := MarkdownChunkPlan{Limit: limitRunes}
|
||||
if limitRunes <= 0 || utf8.RuneCountInString(normalized) <= limitRunes {
|
||||
plan.Chunks = []string{normalized}
|
||||
return plan
|
||||
}
|
||||
scan := scanMarkdownStructure(normalized)
|
||||
plan = scan.emit(plan)
|
||||
if len(plan.Chunks) > 1 && scan.linkRefs > 0 {
|
||||
plan.Degradations = append(plan.Degradations, MarkdownDegradation{
|
||||
Kind: "link_reference_split", Tier: "soft", ChunkIndex: 0, Line: 0,
|
||||
Detail: "文档含链接引用定义,分片后引用与定义可能不在同一片,链接会失效",
|
||||
})
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
// normalizeMarkdownNewlines converts CRLF and lone CR to LF. Normalizing rather
|
||||
// than deleting matters: deleting a lone CR silently joins two lines.
|
||||
//
|
||||
// This runs unconditionally, including on the single-chunk path, so short and
|
||||
// long content never disagree about line endings.
|
||||
func normalizeMarkdownNewlines(content string) string {
|
||||
if !strings.ContainsRune(content, '\r') {
|
||||
return content
|
||||
}
|
||||
return strings.ReplaceAll(strings.ReplaceAll(content, "\r\n", "\n"), "\r", "\n")
|
||||
}
|
||||
|
||||
// emit walks the content, choosing the best available boundary for each window.
|
||||
//
|
||||
// A chunk covering [a, b) is rendered as injection(a).prefix + content[a:b] +
|
||||
// injection(b).suffix, so both halves of a repair are decided by position and a
|
||||
// hard split can never leave a fence hanging open.
|
||||
func (s *markdownScan) emit(plan MarkdownChunkPlan) MarkdownChunkPlan {
|
||||
limit := plan.Limit
|
||||
start, startRune := 0, 0
|
||||
skipReported := false
|
||||
for {
|
||||
prefix, owed, dropped := s.effectiveInjection(start, limit)
|
||||
if dropped && !skipReported {
|
||||
skipReported = true
|
||||
plan.recordKind(s.lineOf(start), "repair_skipped", "soft",
|
||||
"该结构的修复文本本身就超过分片上限,已放弃保留其结构")
|
||||
}
|
||||
prefixRunes := utf8.RuneCountInString(prefix)
|
||||
if s.runesFrom(startRune) <= limit-prefixRunes {
|
||||
// The tail carries the region's own terminator, so nothing is owed.
|
||||
plan.Chunks = append(plan.Chunks, prefix+s.content[start:])
|
||||
return plan
|
||||
}
|
||||
|
||||
cand, ok := s.pick(start, startRune, limit-prefixRunes, limit)
|
||||
if !ok {
|
||||
reserve := 0
|
||||
if owed != "" {
|
||||
reserve = utf8.RuneCountInString(owed) + 1
|
||||
}
|
||||
cand = s.hardCandidate(start, startRune, limit-prefixRunes-reserve)
|
||||
}
|
||||
|
||||
body := s.content[start:cand.offset]
|
||||
if !cand.keepTrailing {
|
||||
body = strings.TrimRight(body, " \t\n")
|
||||
}
|
||||
_, suffix, _ := s.effectiveInjection(cand.offset, limit)
|
||||
sep := ""
|
||||
if suffix != "" && body != "" && !strings.HasSuffix(body, "\n") {
|
||||
sep = "\n" // the closing marker must start its own line
|
||||
}
|
||||
chunk := prefix + body + sep + suffix
|
||||
if strings.TrimSpace(chunk) != "" {
|
||||
plan.Chunks = append(plan.Chunks, chunk)
|
||||
plan.record(s, cand, len(plan.Chunks)-1, suffix, limit)
|
||||
}
|
||||
start, startRune = cand.offset, cand.runeIdx
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveInjection is injectionAt with the repair dropped when it cannot fit,
|
||||
// which is the only way to keep the per-window budget positive for a structure
|
||||
// whose own header or fence line is longer than the limit.
|
||||
func (s *markdownScan) effectiveInjection(offset, limit int) (prefix, suffix string, dropped bool) {
|
||||
p, sfx := s.injectionAt(offset)
|
||||
if p == "" && sfx == "" {
|
||||
return "", "", false
|
||||
}
|
||||
prefixCost, suffixCost := s.injectionCostAt(offset)
|
||||
if prefixCost+suffixCost >= limit {
|
||||
return "", "", true
|
||||
}
|
||||
return p, sfx, false
|
||||
}
|
||||
|
||||
// pick returns the best boundary in [start, start+budget]: the highest tier with
|
||||
// any candidate, and within that tier the latest one.
|
||||
//
|
||||
// Taking the latest candidate within a tier is not just an optimization. Every
|
||||
// chunk is appended to the same document, so within tierSafe the choice has no
|
||||
// effect on the final document at all — cutting early only costs extra chunks
|
||||
// and extra round trips.
|
||||
func (s *markdownScan) pick(start, startRune, budget, limit int) (splitCandidate, bool) {
|
||||
maxRune := startRune + budget
|
||||
for tier := splitTier(0); tier < splitTierCount; tier++ {
|
||||
list := s.byTier[tier]
|
||||
// Candidates are stored in ascending offset, so the binary search finds
|
||||
// the first one past the window; everything before it is a candidate to
|
||||
// walk back through.
|
||||
hi := sort.Search(len(list), func(k int) bool { return s.cands[list[k]].trimRuneIdx > maxRune })
|
||||
for j := hi - 1; j >= 0; j-- {
|
||||
c := s.cands[list[j]]
|
||||
if c.offset <= start {
|
||||
break // this tier is exhausted within the window
|
||||
}
|
||||
prefix, suffix := s.injectionCostAt(c.offset)
|
||||
if c.trimRuneIdx+suffix > maxRune {
|
||||
continue // the closing injection does not fit
|
||||
}
|
||||
if prefix+suffix >= limit {
|
||||
// The repair would consume the whole next window, so this
|
||||
// candidate can never make progress. Rejecting it here is what
|
||||
// removes the "repair does not fit" livelock: every accepted
|
||||
// candidate leaves the next window at least one rune.
|
||||
continue
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return splitCandidate{}, false
|
||||
}
|
||||
|
||||
// hardCandidate is the last resort, used only when the window holds no
|
||||
// structural boundary at all — in practice, a single line longer than the limit.
|
||||
// It prefers the last whitespace within a bounded lookback so words survive,
|
||||
// and falls back to an exact rune cut.
|
||||
func (s *markdownScan) hardCandidate(start, startRune, budget int) splitCandidate {
|
||||
// budget is always at least 1: effectiveInjection drops any repair whose
|
||||
// prefix plus suffix would reach the limit, so the caller's
|
||||
// limit-prefix-reserve arithmetic cannot go non-positive.
|
||||
const lookback = 64
|
||||
off, n := start, 0
|
||||
spaceOff, spaceRune := -1, 0
|
||||
for off < len(s.content) && n < budget {
|
||||
r, size := utf8.DecodeRuneInString(s.content[off:])
|
||||
off += size
|
||||
n++
|
||||
// Deliberately not '\n': a line boundary is a structural decision, and
|
||||
// the tiers above already rejected every one in this window. Cutting at
|
||||
// a newline here would resurrect exactly the boundaries they refused —
|
||||
// a setext underline, for instance.
|
||||
if r == ' ' || r == '\t' {
|
||||
spaceOff, spaceRune = off, n
|
||||
}
|
||||
}
|
||||
kind := candHardRune
|
||||
if spaceOff > start && n-spaceRune <= lookback {
|
||||
kind, off, n = candHardWhitespace, spaceOff, spaceRune
|
||||
}
|
||||
if s.offsetInHTML(start) {
|
||||
// A hard cut inside HTML breaks a tag (this is a single oversized HTML
|
||||
// line with no safe boundary). Flag it so the caller can point the user
|
||||
// at uploading the block as a file instead.
|
||||
kind = candHardHTML
|
||||
}
|
||||
return splitCandidate{
|
||||
offset: off, runeIdx: startRune + n, line: int32(s.lineOf(off)),
|
||||
region: -1, tier: tierRepair, kind: kind, keepTrailing: true,
|
||||
}
|
||||
}
|
||||
|
||||
// offsetInHTML reports whether the byte offset sits inside an HTML region.
|
||||
func (s *markdownScan) offsetInHTML(offset int) bool {
|
||||
r := s.lines[s.lineIndexOf(offset)].region
|
||||
return r >= 0 && s.regions[r].kind == regionHTML
|
||||
}
|
||||
|
||||
// lineOf returns the 1-based line number containing the given byte offset. The
|
||||
// first line always starts at 0, so the search never returns 0.
|
||||
func (s *markdownScan) lineOf(offset int) int {
|
||||
return sort.Search(len(s.lines), func(k int) bool { return s.lines[k].start > offset })
|
||||
}
|
||||
|
||||
// record appends a degradation for the boundary, if it changed anything.
|
||||
func (p *MarkdownChunkPlan) record(s *markdownScan, c splitCandidate, chunkIndex int, suffix string, limit int) {
|
||||
kind, tier, detail := degradationFor(c.kind)
|
||||
if kind == "" {
|
||||
return
|
||||
}
|
||||
// Report the injection that was actually used, not the one injectionAt would
|
||||
// like to use: when the repair is dropped for not fitting, nothing is added.
|
||||
prefix, _, _ := s.effectiveInjection(c.offset, limit)
|
||||
p.Degradations = append(p.Degradations, MarkdownDegradation{
|
||||
Kind: kind, Tier: tier, ChunkIndex: chunkIndex, Line: int(c.line),
|
||||
Detail: detail, InjectedSuffix: suffix, InjectedPrefix: prefix,
|
||||
})
|
||||
}
|
||||
|
||||
// recordKind appends a degradation that no candidate describes.
|
||||
func (p *MarkdownChunkPlan) recordKind(line int, kind, tier, detail string) {
|
||||
p.Degradations = append(p.Degradations, MarkdownDegradation{
|
||||
Kind: kind, Tier: tier, ChunkIndex: len(p.Chunks), Line: line, Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
func degradationFor(kind candidateKind) (string, string, string) {
|
||||
switch kind {
|
||||
case candParagraphLine:
|
||||
return "paragraph_split", "soft", "长段落被拆成多个段落"
|
||||
case candListItem:
|
||||
return "list_split", "soft", "列表被拆成多个列表"
|
||||
case candQuoteLine:
|
||||
return "blockquote_split", "soft", "引用块被拆成多个引用块"
|
||||
case candRegionBoundary:
|
||||
return "block_boundary_split", "soft", "在块边界处切分,前后两块的解析结果可能与整体不同"
|
||||
case candIndentedCodeLine:
|
||||
return "indented_code_split", "soft", "缩进代码块被拆成多个代码块"
|
||||
case candHTMLLine:
|
||||
return "html_block_split", "soft", "HTML 块被拆开,标签可能不再配对;内容很大时建议存成文件后用 dws doc import 导入"
|
||||
case candHTMLRepair:
|
||||
return "html_block_split", "repair", "<pre>/<style>/<textarea> 块被拆成多个,每片各自重开并闭合标签;内容很大时可存成文件后用 dws doc import 导入"
|
||||
case candOrderedListItem:
|
||||
return "ordered_list_split", "repair", "有序列表被拆开,后续分片的编号可能从 1 重新开始"
|
||||
case candTableRow:
|
||||
return "table_split", "repair", "表格被拆成多个表格,后续分片重复表头行与分隔行"
|
||||
case candFenceBody:
|
||||
return "code_block_split", "repair", "代码块被拆成多个代码块,每片各自闭合围栏"
|
||||
case candHardWhitespace:
|
||||
return "hard_split_at_whitespace", "rune", "无结构切分点,已在空白处硬切"
|
||||
case candHardRune:
|
||||
return "hard_rune_split", "rune", "单行长度超过上限,已按字符硬切"
|
||||
case candHardHTML:
|
||||
return "html_tag_hard_split", "rune", "HTML 内容无法在不破坏标签的前提下切分,已硬切;建议将内容存成文件后用 dws doc import 导入"
|
||||
default:
|
||||
return "", "", "" // tierSafe boundaries change nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,764 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// stripInjections undoes every repair the plan recorded, so what remains must be
|
||||
// the original content. Stripping is anchored (TrimSuffix on the chunk before the
|
||||
// boundary, TrimPrefix on the chunk after it) rather than a substring search,
|
||||
// which is why MarkdownDegradation reports the two halves separately.
|
||||
func stripInjections(p MarkdownChunkPlan) []string {
|
||||
out := append([]string(nil), p.Chunks...)
|
||||
for _, d := range p.Degradations {
|
||||
if d.InjectedSuffix != "" && d.ChunkIndex < len(out) {
|
||||
out[d.ChunkIndex] = strings.TrimSuffix(out[d.ChunkIndex], d.InjectedSuffix)
|
||||
}
|
||||
if d.InjectedPrefix != "" && d.ChunkIndex+1 < len(out) {
|
||||
out[d.ChunkIndex+1] = strings.TrimPrefix(out[d.ChunkIndex+1], d.InjectedPrefix)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropWhitespace removes every space, tab and newline. Comparing content with
|
||||
// whitespace removed is the strongest no-loss statement compatible with repairs:
|
||||
// boundary whitespace legitimately moves, but no other character may appear or
|
||||
// disappear.
|
||||
func dropWhitespace(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == ' ' || r == '\t' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, s)
|
||||
}
|
||||
|
||||
// hasUnclosedFence replays the production fence rule over a single chunk. A
|
||||
// naive count of "```" cannot do this: a four-backtick fence legitimately
|
||||
// contains three-backtick lines, so only the state machine knows whether the
|
||||
// chunk ends inside a code block.
|
||||
func hasUnclosedFence(chunk string) bool {
|
||||
scan := scanMarkdownStructure(normalizeMarkdownNewlines(chunk))
|
||||
for _, r := range scan.regions {
|
||||
if r.kind == regionFence && scan.lines[r.lastLine].kind != lineFenceClose {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func degradationKinds(p MarkdownChunkPlan) []string {
|
||||
kinds := make([]string, 0, len(p.Degradations))
|
||||
for _, d := range p.Degradations {
|
||||
kinds = append(kinds, d.Kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownChunkTierSelection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
limit int
|
||||
want []string
|
||||
wantKind []string
|
||||
}{{
|
||||
name: "blank line boundary is invisible",
|
||||
content: "alpha\n\nbravo\n\ncharlie",
|
||||
limit: 12,
|
||||
want: []string{"alpha\n\nbravo", "charlie"},
|
||||
}, {
|
||||
// The heading interrupts the paragraph, so cutting right before it is
|
||||
// safe even without a blank line. This is the boundary the previous
|
||||
// implementation corrupted into "para# Title".
|
||||
name: "heading interrupts a paragraph",
|
||||
content: "para\n# Title",
|
||||
limit: 8,
|
||||
want: []string{"para", "# Title"},
|
||||
}, {
|
||||
// Greedy-latest within a tier: every chunk lands in the same document,
|
||||
// so an earlier safe boundary would only cost an extra round trip.
|
||||
// Aligning to the heading here would emit 3 chunks (10/84/30).
|
||||
name: "greedy within the safe tier",
|
||||
content: strings.Repeat("a", 10) + "\n\n# h\n\n" + strings.Repeat("b", 80) + "\n\n" + strings.Repeat("c", 30),
|
||||
limit: 100,
|
||||
want: []string{
|
||||
strings.Repeat("a", 10) + "\n\n# h\n\n" + strings.Repeat("b", 80),
|
||||
strings.Repeat("c", 30),
|
||||
},
|
||||
}, {
|
||||
name: "paragraph soft break becomes two paragraphs",
|
||||
content: "first line\nsecond line",
|
||||
limit: 12,
|
||||
want: []string{"first line", "second line"},
|
||||
wantKind: []string{"paragraph_split"},
|
||||
}, {
|
||||
name: "unordered list splits between items",
|
||||
content: "- alpha\n- bravo\n- charlie",
|
||||
limit: 10,
|
||||
want: []string{"- alpha", "- bravo", "- charlie"},
|
||||
wantKind: []string{"list_split", "list_split"},
|
||||
}, {
|
||||
// Ordered lists are repair tier, not soft: the server renumbers from 1,
|
||||
// which is a visible change an unordered split does not cause.
|
||||
name: "ordered list split is heavier than unordered",
|
||||
content: "1. alpha\n2. bravo",
|
||||
limit: 10,
|
||||
want: []string{"1. alpha", "2. bravo"},
|
||||
wantKind: []string{"ordered_list_split"},
|
||||
}, {
|
||||
name: "blockquote splits between lines",
|
||||
content: "> one\n> two\n> three",
|
||||
limit: 8,
|
||||
want: []string{"> one", "> two", "> three"},
|
||||
wantKind: []string{"blockquote_split", "blockquote_split"},
|
||||
}, {
|
||||
// Each half is still an indented code block, so no injection is needed —
|
||||
// but one block becomes two.
|
||||
name: "indented code splits at a line boundary",
|
||||
content: " code a\n code b",
|
||||
limit: 12,
|
||||
want: []string{" code a", " code b"},
|
||||
wantKind: []string{"indented_code_split"},
|
||||
}, {
|
||||
name: "single long line falls back to whitespace",
|
||||
content: "aaa bbb ccc ddd",
|
||||
limit: 8,
|
||||
want: []string{"aaa bbb ", "ccc ddd"},
|
||||
wantKind: []string{"hard_split_at_whitespace"},
|
||||
}, {
|
||||
name: "single long word falls back to runes",
|
||||
content: strings.Repeat("界", 7),
|
||||
limit: 3,
|
||||
want: []string{"界界界", "界界界", "界"},
|
||||
wantKind: []string{"hard_rune_split", "hard_rune_split"},
|
||||
}, {
|
||||
name: "leading blank lines never emit an empty chunk",
|
||||
content: "\n\n\nalpha\n\nbravo",
|
||||
limit: 6,
|
||||
want: []string{"alpha", "bravo"},
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := SplitMarkdownForAppend(tc.content, tc.limit)
|
||||
if len(plan.Chunks) != len(tc.want) {
|
||||
t.Fatalf("chunks = %#v, want %#v", plan.Chunks, tc.want)
|
||||
}
|
||||
for i, want := range tc.want {
|
||||
if plan.Chunks[i] != want {
|
||||
t.Errorf("chunk %d = %q, want %q", i, plan.Chunks[i], want)
|
||||
}
|
||||
}
|
||||
if got := degradationKinds(plan); strings.Join(got, ",") != strings.Join(tc.wantKind, ",") {
|
||||
t.Errorf("degradations = %v, want %v", got, tc.wantKind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownChunkRepairsTablesAndFences(t *testing.T) {
|
||||
t.Run("table repeats header and delimiter", func(t *testing.T) {
|
||||
content := "| 姓名 | 部门 |\n|---|---|\n" + strings.Repeat("| 张三 | 技术部 |\n", 5)
|
||||
plan := SplitMarkdownForAppend(content, 60)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("expected a split, got %#v", plan.Chunks)
|
||||
}
|
||||
header := "| 姓名 | 部门 |\n|---|---|\n"
|
||||
for i, chunk := range plan.Chunks {
|
||||
if !strings.HasPrefix(chunk, header) {
|
||||
t.Errorf("chunk %d lost the header: %q", i, chunk)
|
||||
}
|
||||
}
|
||||
for _, d := range plan.Degradations {
|
||||
if d.Kind != "table_split" || d.InjectedPrefix != header {
|
||||
t.Errorf("degradation = %#v", d)
|
||||
}
|
||||
}
|
||||
// The repeated header is charged to the chunk that carries it.
|
||||
for i, chunk := range plan.Chunks {
|
||||
if n := utf8.RuneCountInString(chunk); n > 60 {
|
||||
t.Errorf("chunk %d is %d runes, over the limit", i, n)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fence closes and reopens with the original marker", func(t *testing.T) {
|
||||
for _, open := range []string{"```go", "````", "~~~~", "~~~ yaml"} {
|
||||
marker := open[:strings.LastIndexAny(open, "`~")+1]
|
||||
content := open + "\n" + strings.Repeat("body\n", 6) + marker
|
||||
plan := SplitMarkdownForAppend(content, 30)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("%q: expected a split, got %#v", open, plan.Chunks)
|
||||
}
|
||||
for i, chunk := range plan.Chunks {
|
||||
if !strings.HasPrefix(chunk, open) {
|
||||
t.Errorf("%q chunk %d lost the opening fence: %q", open, i, chunk)
|
||||
}
|
||||
if !strings.HasSuffix(chunk, marker) {
|
||||
t.Errorf("%q chunk %d is not closed: %q", open, i, chunk)
|
||||
}
|
||||
if hasUnclosedFence(chunk) {
|
||||
t.Errorf("%q chunk %d leaves a fence open: %q", open, i, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fence interior keeps trailing blank lines", func(t *testing.T) {
|
||||
content := "```\nalpha\n\n\nbravo\ncharlie\ndelta\n```"
|
||||
plan := SplitMarkdownForAppend(content, 22)
|
||||
joined := strings.Join(stripInjections(plan), "")
|
||||
if dropWhitespace(joined) != dropWhitespace(content) {
|
||||
t.Fatalf("content changed: %q", joined)
|
||||
}
|
||||
if !strings.Contains(strings.Join(plan.Chunks, "|"), "alpha\n\n\n") {
|
||||
t.Errorf("blank lines inside the fence were trimmed: %#v", plan.Chunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repair wider than the limit is dropped and reported", func(t *testing.T) {
|
||||
// The reopening fence alone is longer than the limit, so preserving the
|
||||
// structure is impossible; the splitter must say so rather than emit
|
||||
// chunks over the limit or loop forever.
|
||||
content := "```" + strings.Repeat("x", 40) + "\n" + strings.Repeat("body\n", 4) + "```"
|
||||
plan := SplitMarkdownForAppend(content, 20)
|
||||
for i, chunk := range plan.Chunks {
|
||||
if n := utf8.RuneCountInString(chunk); n > 20 {
|
||||
t.Errorf("chunk %d is %d runes, over the limit", i, n)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(degradationKinds(plan), ","), "repair_skipped") {
|
||||
t.Errorf("expected repair_skipped, got %v", degradationKinds(plan))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownChunkHazards(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
limit int
|
||||
// forbidden is a boundary that must never be chosen, expressed as the
|
||||
// exact text a chunk would end with if it were.
|
||||
forbidden string
|
||||
wantKinds string
|
||||
}{{
|
||||
// `para\n---` is a setext H2, not a paragraph followed by a rule.
|
||||
// Splitting there turns one heading into a paragraph plus a horizontal
|
||||
// rule, so the dash line must not be treated as a block start.
|
||||
name: "setext underline with dashes",
|
||||
content: "para\n---\n\ntail text here",
|
||||
limit: 10,
|
||||
forbidden: "para",
|
||||
}, {
|
||||
name: "setext underline with equals",
|
||||
content: "para\n===\n\ntail text here",
|
||||
limit: 10,
|
||||
forbidden: "para",
|
||||
}, {
|
||||
// A dash rule *is* safe when no paragraph is open above it.
|
||||
name: "dash rule after a blank line is safe",
|
||||
content: "alpha\n\n---\n\nbravo",
|
||||
limit: 9,
|
||||
}, {
|
||||
// `***` cannot be read as a setext underline, so it always interrupts.
|
||||
name: "asterisk rule interrupts a paragraph",
|
||||
content: "para\n***\nmore text",
|
||||
limit: 9,
|
||||
}, {
|
||||
// The blank line belongs to the code block, so it is not a safe boundary.
|
||||
// Cutting is still allowed at the code block's own line boundaries, but
|
||||
// it must be reported rather than treated as invisible.
|
||||
name: "indented code with an interior blank line",
|
||||
content: " alpha\n\n bravo",
|
||||
limit: 11,
|
||||
wantKinds: "indented_code_split",
|
||||
}, {
|
||||
// A loose list's interior blank line is inside the list.
|
||||
name: "loose list interior blank line",
|
||||
content: "- alpha\n\n more\n- bravo",
|
||||
limit: 16,
|
||||
forbidden: "- alpha",
|
||||
}, {
|
||||
// Whether a table interrupts a paragraph is parser-dependent, so this
|
||||
// boundary is allowed but must be reported, never treated as safe.
|
||||
name: "table header after a paragraph line is not safe",
|
||||
content: "para\n| a | b |\n|---|---|\n| 1 | 2 |",
|
||||
limit: 24,
|
||||
wantKinds: "block_boundary_split",
|
||||
}, {
|
||||
name: "html block split is reported",
|
||||
content: "<div>\nalpha\nbravo\n</div>",
|
||||
limit: 12,
|
||||
wantKinds: "html_block_split",
|
||||
}, {
|
||||
name: "link reference definitions are reported",
|
||||
content: "[ref]: https://example.com/one\n\nalpha text\n\nbravo text",
|
||||
limit: 16,
|
||||
wantKinds: "link_reference_split",
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := SplitMarkdownForAppend(tc.content, tc.limit)
|
||||
for i, chunk := range plan.Chunks {
|
||||
if n := utf8.RuneCountInString(chunk); n > tc.limit {
|
||||
t.Errorf("chunk %d is %d runes, over the limit: %q", i, n, chunk)
|
||||
}
|
||||
}
|
||||
if tc.forbidden != "" {
|
||||
for i, chunk := range plan.Chunks {
|
||||
if chunk == tc.forbidden {
|
||||
t.Errorf("chunk %d cut at a forbidden boundary: %#v", i, plan.Chunks)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tc.wantKinds != "" {
|
||||
got := strings.Join(degradationKinds(plan), ",")
|
||||
if !strings.Contains(got, tc.wantKinds) {
|
||||
t.Errorf("degradations = %q, want to contain %q", got, tc.wantKinds)
|
||||
}
|
||||
}
|
||||
joined := strings.Join(stripInjections(plan), "")
|
||||
if dropWhitespace(joined) != dropWhitespace(normalizeMarkdownNewlines(tc.content)) {
|
||||
t.Errorf("content changed:\n got %q\nwant %q", joined, tc.content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownChunkEdgeContracts(t *testing.T) {
|
||||
t.Run("limit disables splitting", func(t *testing.T) {
|
||||
// Callers rely on this as an explicit "send it in one call whatever the
|
||||
// size" escape hatch.
|
||||
for _, limit := range []int{0, -1} {
|
||||
plan := SplitMarkdownForAppend("alpha\nbravo\ncharlie", limit)
|
||||
if len(plan.Chunks) != 1 || plan.Chunks[0] != "alpha\nbravo\ncharlie" {
|
||||
t.Fatalf("limit=%d chunks = %#v", limit, plan.Chunks)
|
||||
}
|
||||
if plan.Degraded() {
|
||||
t.Errorf("limit=%d must not degrade: %#v", limit, plan.Degradations)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty content yields one empty chunk", func(t *testing.T) {
|
||||
// chunkedWrite and the doc shortcuts both index Chunks[0] unguarded.
|
||||
plan := SplitMarkdownForAppend("", DefaultMarkdownChunkRunes)
|
||||
if len(plan.Chunks) != 1 || plan.Chunks[0] != "" {
|
||||
t.Fatalf("chunks = %#v", plan.Chunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("newlines are normalized on the single-chunk path too", func(t *testing.T) {
|
||||
// Normalizing rather than deleting matters: deleting a lone CR silently
|
||||
// joins two lines. Doing it unconditionally matters so short and long
|
||||
// content never disagree.
|
||||
for _, limit := range []int{5, DefaultMarkdownChunkRunes} {
|
||||
plan := SplitMarkdownForAppend("a\r\nb\rc", limit)
|
||||
if got := strings.Join(plan.Chunks, ""); !strings.Contains(got, "a\nb\nc") {
|
||||
t.Errorf("limit=%d joined = %q, want CRLF and lone CR normalized", limit, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace-only content", func(t *testing.T) {
|
||||
plan := SplitMarkdownForAppend("\n\n\n\n\n\n\n\n", 3)
|
||||
if len(plan.Chunks) == 0 {
|
||||
t.Fatal("plan must always hold at least one chunk")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projections", func(t *testing.T) {
|
||||
plan := SplitMarkdownForAppend("first line\nsecond line", 12)
|
||||
if !plan.Degraded() {
|
||||
t.Fatalf("expected a degraded plan: %#v", plan)
|
||||
}
|
||||
if got := plan.ExpectedDocument(); got != "first line\n\nsecond line" {
|
||||
t.Errorf("ExpectedDocument = %q", got)
|
||||
}
|
||||
warnings := plan.Warnings()
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "段落") {
|
||||
t.Errorf("Warnings = %#v", warnings)
|
||||
}
|
||||
summary := plan.Summary()
|
||||
if summary["chunks"] != 2 || summary["degraded"] != true || summary["limit"] != 12 {
|
||||
t.Errorf("Summary = %#v", summary)
|
||||
}
|
||||
|
||||
single := SplitMarkdownForAppend("short", DefaultMarkdownChunkRunes)
|
||||
if single.Degraded() || single.Warnings() != nil {
|
||||
t.Errorf("single chunk must not degrade: %#v", single)
|
||||
}
|
||||
if got := single.ExpectedDocument(); got != "short" {
|
||||
t.Errorf("single ExpectedDocument = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("warnings aggregate repeated kinds", func(t *testing.T) {
|
||||
plan := SplitMarkdownForAppend("- a\n- b\n- c\n- d", 4)
|
||||
warnings := plan.Warnings()
|
||||
if len(warnings) != 1 || !strings.Contains(warnings[0], "处") {
|
||||
t.Errorf("Warnings = %#v", warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageMarkdownChunkInvariantsOnSeededCorpus is the
|
||||
// regression net for the whole algorithm. The seed is fixed so a failure is
|
||||
// reproducible, and the corpus crosses every block type against limits small
|
||||
// enough to force each tier.
|
||||
//
|
||||
// The old block-rebuilding splitter failed the content invariant on roughly one
|
||||
// input in five, because it reassembled text instead of slicing it.
|
||||
func TestCrossPlatformCoverageMarkdownChunkInvariantsOnSeededCorpus(t *testing.T) {
|
||||
generators := []func(n int) string{
|
||||
func(n int) string { return fmt.Sprintf("# Heading %d", n) },
|
||||
func(n int) string { return fmt.Sprintf("### Heading %d", n) },
|
||||
func(n int) string { return strings.Repeat("文字", 1+n%9) },
|
||||
func(n int) string { return "first line\nsecond line\nthird line" },
|
||||
func(n int) string { return "| a | b |\n|---|---|\n" + strings.Repeat("| 1 | 2 |\n", 1+n%5) },
|
||||
func(n int) string { return "```go\n" + strings.Repeat("code\n", 1+n%4) + "```" },
|
||||
func(n int) string { return "~~~~\n" + strings.Repeat("t\n", 1+n%3) + "~~~~" },
|
||||
func(n int) string { return "````\n``` nested\n````" },
|
||||
func(n int) string { return "- alpha\n- bravo\n- charlie" },
|
||||
func(n int) string { return "1. alpha\n2. bravo\n3. charlie" },
|
||||
func(n int) string { return "- alpha\n\n continued\n- bravo" },
|
||||
func(n int) string { return "> quoted one\n> quoted two" },
|
||||
func(n int) string { return " indented\n\n code" },
|
||||
func(n int) string { return "setext heading\n---" },
|
||||
func(n int) string { return "setext heading\n===" },
|
||||
func(n int) string { return "***" },
|
||||
func(n int) string { return strings.Repeat("x", 30+n%40) },
|
||||
func(n int) string { return "word " + strings.Repeat("wordy ", 6+n%5) },
|
||||
func(n int) string { return "a\r\nb\rc" },
|
||||
func(n int) string { return "<div>\nhtml body\n</div>" },
|
||||
func(n int) string { return "<script>\n\nstill script\n</script>" },
|
||||
func(n int) string { return "<pre>\n" + strings.Repeat("pre line\n", 1+n%4) + "</pre>" },
|
||||
func(n int) string { return "<pre>\nfirst\n\nafter blank\n</pre>" },
|
||||
func(n int) string { return "<pre\n class=\"c\">\n" + strings.Repeat("x\n", 1+n%3) + "</pre>" },
|
||||
func(n int) string { return "<style>\n" + strings.Repeat(".a{b:1}\n", 1+n%3) + "</style>" },
|
||||
func(n int) string { return "<textarea>\n" + strings.Repeat("row\n", 1+n%3) + "</textarea>" },
|
||||
func(n int) string { return "[ref]: https://example.com/x" },
|
||||
}
|
||||
limits := []int{1, 2, 3, 7, 17, 64, 200}
|
||||
rng := rand.New(rand.NewSource(20260817))
|
||||
|
||||
for iter := 0; iter < 4000; iter++ {
|
||||
var parts []string
|
||||
for k := 0; k < 1+rng.Intn(6); k++ {
|
||||
parts = append(parts, generators[rng.Intn(len(generators))](rng.Intn(10)))
|
||||
}
|
||||
content := strings.Join(parts, "\n\n")
|
||||
limit := limits[rng.Intn(len(limits))]
|
||||
plan := SplitMarkdownForAppend(content, limit)
|
||||
normalized := normalizeMarkdownNewlines(content)
|
||||
|
||||
fail := func(format string, args ...any) {
|
||||
t.Fatalf("seed=20260817 iter=%d limit=%d content=%q\n"+format,
|
||||
append([]any{iter, limit, normalized}, args...)...)
|
||||
}
|
||||
|
||||
// I1: a plan always has an indexable first chunk.
|
||||
if len(plan.Chunks) == 0 {
|
||||
fail("empty plan")
|
||||
}
|
||||
// I2: no chunk exceeds the limit, injections included.
|
||||
for i, chunk := range plan.Chunks {
|
||||
if n := utf8.RuneCountInString(chunk); n > limit {
|
||||
fail("chunk %d is %d runes: %q", i, n, chunk)
|
||||
}
|
||||
}
|
||||
// I3: stripping the recorded repairs recovers the content exactly.
|
||||
if got := dropWhitespace(strings.Join(stripInjections(plan), "")); got != dropWhitespace(normalized) {
|
||||
fail("content changed:\n got %q\nwant %q", got, dropWhitespace(normalized))
|
||||
}
|
||||
// I4 and I5 assume the structure could be preserved. When the limit is
|
||||
// smaller than a fence line or a table header, the plan says so via
|
||||
// repair_skipped and no self-containedness claim is being made.
|
||||
if strings.Contains(strings.Join(degradationKinds(plan), ","), "repair_skipped") {
|
||||
continue
|
||||
}
|
||||
// I4: no chunk ends inside a code block.
|
||||
for i, chunk := range plan.Chunks {
|
||||
if hasUnclosedFence(chunk) {
|
||||
fail("chunk %d leaves a fence open: %q", i, chunk)
|
||||
}
|
||||
}
|
||||
// I5: a table body row is never orphaned from its header, so no chunk
|
||||
// may open with a delimiter row.
|
||||
for i, chunk := range plan.Chunks {
|
||||
if first := strings.SplitN(chunk, "\n", 2)[0]; isTableDelimiterRow(strings.TrimSpace(first)) {
|
||||
fail("chunk %d starts with a delimiter row: %q", i, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageMarkdownChunkDegradationBookkeeping pins the contracts
|
||||
// that decide the ExpectedDocument / degradations story every caller depends on.
|
||||
// The seeded corpus exercises these paths but asserts only aggregate invariants;
|
||||
// these cases assert the exact field values, so an off-by-one in ChunkIndex or a
|
||||
// wrong ExpectedDocument join can never pass silently.
|
||||
func TestCrossPlatformCoverageMarkdownChunkDegradationBookkeeping(t *testing.T) {
|
||||
t.Run("ChunkIndex points at the chunk before each boundary", func(t *testing.T) {
|
||||
// Four list items, each within the limit, split into four chunks with a
|
||||
// degradation at each of the three boundaries, indexed 0,1,2.
|
||||
plan := SplitMarkdownForAppend("- aa\n- bb\n- cc\n- dd", 5)
|
||||
if len(plan.Chunks) != 4 || len(plan.Degradations) != 3 {
|
||||
t.Fatalf("chunks=%#v degradations=%#v", plan.Chunks, plan.Degradations)
|
||||
}
|
||||
for i, d := range plan.Degradations {
|
||||
if d.ChunkIndex != i {
|
||||
t.Errorf("degradation %d has ChunkIndex %d, want %d", i, d.ChunkIndex, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExpectedDocument carries the repeated table header", func(t *testing.T) {
|
||||
// This is the case ExpectedDocument exists for: after a table repair the
|
||||
// server holds N tables, so verification must expect the repeated header,
|
||||
// not the single-table input.
|
||||
header := "| a | b |\n|---|---|\n"
|
||||
content := header + strings.Repeat("| 1 | 2 |\n", 6)
|
||||
plan := SplitMarkdownForAppend(content, 40)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("fixture must split, got %d chunk(s)", len(plan.Chunks))
|
||||
}
|
||||
expected := plan.ExpectedDocument()
|
||||
// The header appears once per chunk, i.e. more often than in the input.
|
||||
if got, want := strings.Count(expected, "| a | b |"), len(plan.Chunks); got != want {
|
||||
t.Errorf("header appears %d times in ExpectedDocument, want %d", got, want)
|
||||
}
|
||||
// ExpectedDocument is exactly the chunks joined with a blank line, and it
|
||||
// re-parses without any orphaned delimiter row.
|
||||
if expected != strings.Join(plan.Chunks, "\n\n") {
|
||||
t.Errorf("ExpectedDocument is not the chunks joined by a blank line")
|
||||
}
|
||||
rescan := SplitMarkdownForAppend(expected, len(expected)*4)
|
||||
if len(rescan.Chunks) != 1 || rescan.Degraded() {
|
||||
t.Errorf("ExpectedDocument does not round-trip as one clean document: %#v", rescan)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one document reports every distinct degradation kind", func(t *testing.T) {
|
||||
// A realistic mixed document: an oversized table, an oversized fenced
|
||||
// code block, and a long paragraph, each forced to split by the limit.
|
||||
content := strings.Join([]string{
|
||||
"| a | b |\n|---|---|\n" + strings.Repeat("| 1 | 2 |\n", 6),
|
||||
"```go\n" + strings.Repeat("fmt.Println(1)\n", 6) + "```",
|
||||
strings.Repeat("word ", 40),
|
||||
}, "\n\n")
|
||||
plan := SplitMarkdownForAppend(content, 45)
|
||||
|
||||
kinds := map[string]bool{}
|
||||
lastLineByKind := map[string]int{}
|
||||
for _, d := range plan.Degradations {
|
||||
kinds[d.Kind] = true
|
||||
// Lines within a kind are reported in ascending order.
|
||||
if prev, seen := lastLineByKind[d.Kind]; seen && d.Line < prev {
|
||||
t.Errorf("%s degradation lines out of order: %d after %d", d.Kind, d.Line, prev)
|
||||
}
|
||||
lastLineByKind[d.Kind] = d.Line
|
||||
}
|
||||
for _, want := range []string{"table_split", "code_block_split"} {
|
||||
if !kinds[want] {
|
||||
t.Errorf("missing %s in %v", want, degradationKinds(plan))
|
||||
}
|
||||
}
|
||||
// Stripping every recorded repair still recovers the whole document.
|
||||
if got := dropWhitespace(strings.Join(stripInjections(plan), "")); got != dropWhitespace(content) {
|
||||
t.Errorf("mixed document not recoverable after stripping injections")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("regression: large document ending in a heading keeps the newline", func(t *testing.T) {
|
||||
// The bug that started this rewrite: the old splitter rebuilt block text
|
||||
// and dropped the newline before a trailing heading/table/fence, so a
|
||||
// heading at the very end stopped being a heading. Reproduce it at
|
||||
// production scale.
|
||||
body := strings.Repeat("这是一段正文内容。\n\n", 6000) // ~60k runes, no trailing newline
|
||||
content := body + "## 附录"
|
||||
plan := SplitMarkdownForAppend(content, DefaultMarkdownChunkRunes)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("fixture must exceed the limit, got %d chunk(s)", len(plan.Chunks))
|
||||
}
|
||||
// The heading must survive as its own line, never glued to preceding text.
|
||||
last := plan.Chunks[len(plan.Chunks)-1]
|
||||
if !strings.Contains(last, "\n## 附录") {
|
||||
r := []rune(last)
|
||||
t.Fatalf("trailing heading is not on its own line: %q", string(r[max(0, len(r)-30):]))
|
||||
}
|
||||
// The original bug produced a single line like "正文内容。## 附录". Assert no
|
||||
// chunk has body text and the heading marker on the same line.
|
||||
for i, chunk := range plan.Chunks {
|
||||
for _, line := range strings.Split(chunk, "\n") {
|
||||
if strings.Contains(line, "。##") {
|
||||
t.Errorf("chunk %d glued the heading onto a text line: %q", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And nothing was lost.
|
||||
if got := dropWhitespace(strings.Join(plan.Chunks, "")); got != dropWhitespace(normalizeMarkdownNewlines(content)) {
|
||||
t.Error("content changed at production scale")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("repaired chunks stay within the limit at the production limit", func(t *testing.T) {
|
||||
// The concern: a continuation chunk carries a re-emitted table header, so
|
||||
// could header + rows push it over the limit? No — the header is charged
|
||||
// against the window budget before rows are packed. The seeded corpus
|
||||
// proves this at tiny limits where the header dwarfs the limit; this pins
|
||||
// it at the real DefaultMarkdownChunkRunes with a wide header, which is the
|
||||
// value that actually ships.
|
||||
limit := DefaultMarkdownChunkRunes
|
||||
const cols = 40
|
||||
header := "|" + strings.Repeat(" 列名称占位 |", cols) + "\n"
|
||||
delim := "|" + strings.Repeat("---|", cols) + "\n"
|
||||
row := "|" + strings.Repeat(" 单元格数据 |", cols) + "\n"
|
||||
content := header + delim + strings.Repeat(row, limit/utf8.RuneCountInString(row)+50)
|
||||
|
||||
plan := SplitMarkdownForAppend(content, limit)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("fixture must split, got %d chunk(s)", len(plan.Chunks))
|
||||
}
|
||||
sawInjectedContinuation := false
|
||||
for i, chunk := range plan.Chunks {
|
||||
if n := utf8.RuneCountInString(chunk); n > limit {
|
||||
t.Errorf("chunk %d is %d runes, over the limit %d", i, n, limit)
|
||||
}
|
||||
if i > 0 && strings.HasPrefix(chunk, header) {
|
||||
sawInjectedContinuation = true
|
||||
}
|
||||
}
|
||||
if !sawInjectedContinuation {
|
||||
t.Error("expected at least one continuation chunk carrying the re-emitted header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("closed pre block does not swallow a following oversized table", func(t *testing.T) {
|
||||
// Regression for the type-1 HTML closer bug: <pre>/<style>/<textarea> were
|
||||
// all classified as type 1 but the terminator search only looked for
|
||||
// </script>, so a closed <pre> block swallowed the rest of the document.
|
||||
// A following oversized table would then be cut as HTML lines with no
|
||||
// header re-emitted, leaving invalid tables downstream.
|
||||
header := "| a | b |\n|---|---|\n"
|
||||
table := header + strings.Repeat("| 1 | 2 |\n", 12)
|
||||
for _, open := range []string{"<pre>", "<style>", "<textarea>"} {
|
||||
closer := strings.Replace(open, "<", "</", 1)
|
||||
content := open + "\ninner\n" + closer + "\n\n" + table
|
||||
plan := SplitMarkdownForAppend(content, 40)
|
||||
|
||||
kinds := strings.Join(degradationKinds(plan), ",")
|
||||
if !strings.Contains(kinds, "table_split") {
|
||||
t.Errorf("%s: table was not split as a table (degradations=%q)", open, kinds)
|
||||
}
|
||||
if strings.Contains(kinds, "html_block_split") {
|
||||
t.Errorf("%s: table was cut as HTML lines (degradations=%q)", open, kinds)
|
||||
}
|
||||
// Every continuation chunk of the table carries the re-emitted header.
|
||||
for i, chunk := range plan.Chunks {
|
||||
if i > 0 && strings.Contains(chunk, "| 1 | 2 |") && !strings.HasPrefix(chunk, header) {
|
||||
t.Errorf("%s: chunk %d has table rows without the header: %q", open, i, chunk)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownChunkHTMLRepair(t *testing.T) {
|
||||
t.Run("pre style textarea reopen and close on every chunk", func(t *testing.T) {
|
||||
for _, tc := range []struct{ open, close string }{
|
||||
{"<pre>", "</pre>"}, {"<style>", "</style>"}, {"<textarea>", "</textarea>"},
|
||||
} {
|
||||
content := tc.open + "\n" + strings.Repeat("row\n", 8) + tc.close
|
||||
// Limit large enough for the injected tags plus one content line.
|
||||
plan := SplitMarkdownForAppend(content, len(tc.open)+len(tc.close)+8)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("%s: expected a split, got %d", tc.open, len(plan.Chunks))
|
||||
}
|
||||
for i, chunk := range plan.Chunks {
|
||||
if !strings.HasPrefix(chunk, tc.open) {
|
||||
t.Errorf("%s: chunk %d not reopened: %q", tc.open, i, chunk)
|
||||
}
|
||||
if !strings.HasSuffix(chunk, tc.close) {
|
||||
t.Errorf("%s: chunk %d not closed: %q", tc.open, i, chunk)
|
||||
}
|
||||
}
|
||||
for _, d := range plan.Degradations {
|
||||
if d.Kind != "html_block_split" || d.Tier != "repair" || d.InjectedPrefix == "" {
|
||||
t.Errorf("%s: degradation = %#v", tc.open, d)
|
||||
}
|
||||
}
|
||||
// Content recoverable after stripping the injected tags.
|
||||
if got := dropWhitespace(strings.Join(stripInjections(plan), "")); got != dropWhitespace(content) {
|
||||
t.Errorf("%s: content changed after strip: %q", tc.open, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-line opening tag is reopened in full", func(t *testing.T) {
|
||||
open := "<pre\n class=\"code\"\n id=\"x\">"
|
||||
content := open + "\n" + strings.Repeat("content line\n", 6) + "</pre>"
|
||||
plan := SplitMarkdownForAppend(content, 60)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("expected a split, got %d", len(plan.Chunks))
|
||||
}
|
||||
for i, chunk := range plan.Chunks {
|
||||
if i > 0 && !strings.HasPrefix(chunk, open) {
|
||||
t.Errorf("chunk %d did not reopen the full multi-line tag: %q", i, chunk)
|
||||
}
|
||||
if n := utf8.RuneCountInString(chunk); n > 60 {
|
||||
t.Errorf("chunk %d is %d runes, over the limit", i, n)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interior blank line is preserved, never a silent safe split", func(t *testing.T) {
|
||||
content := "<pre>\nbefore\n\nafter\n</pre>"
|
||||
plan := SplitMarkdownForAppend(content, 16)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("expected a split, got %d", len(plan.Chunks))
|
||||
}
|
||||
// The blank inside the pre must not be treated as a boundary-free safe
|
||||
// cut: splitting inside a <pre> must be reported, and content survives.
|
||||
if len(plan.Degradations) == 0 {
|
||||
t.Error("splitting inside a <pre> must be reported, not silent")
|
||||
}
|
||||
if got := dropWhitespace(strings.Join(stripInjections(plan), "")); got != dropWhitespace(content) {
|
||||
t.Errorf("blank line lost: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("script is never repaired", func(t *testing.T) {
|
||||
content := "<script>\n" + strings.Repeat("var x=1\n", 6) + "</script>"
|
||||
plan := SplitMarkdownForAppend(content, 20)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("expected a split, got %d", len(plan.Chunks))
|
||||
}
|
||||
for i, chunk := range plan.Chunks {
|
||||
if i > 0 && strings.HasPrefix(chunk, "<script>") {
|
||||
t.Errorf("chunk %d reopened <script> — script must not be repaired: %q", i, chunk)
|
||||
}
|
||||
}
|
||||
for _, d := range plan.Degradations {
|
||||
if d.Tier == "repair" && d.Kind == "html_block_split" {
|
||||
t.Errorf("script was repaired: %#v", d)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized single HTML line reports html_tag_hard_split with upload guidance", func(t *testing.T) {
|
||||
content := "<div>" + strings.Repeat("x", 60) + "</div>"
|
||||
plan := SplitMarkdownForAppend(content, 20)
|
||||
if !strings.Contains(strings.Join(degradationKinds(plan), ","), "html_tag_hard_split") {
|
||||
t.Fatalf("expected html_tag_hard_split, got %v", degradationKinds(plan))
|
||||
}
|
||||
joined := strings.Join(plan.Warnings(), " ")
|
||||
if !strings.Contains(joined, "doc import") {
|
||||
t.Errorf("warning must point at dws doc import: %q", joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These are the CommonMark/GFM line predicates the whole tier model rests on.
|
||||
// They are tested directly because a misclassification here silently downgrades
|
||||
// or upgrades a split tier, and an upgraded tier corrupts documents.
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownScanLinePredicates(t *testing.T) {
|
||||
t.Run("leadingIndent counts tabs as four columns", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
want int
|
||||
}{
|
||||
{"none", 0}, {" two", 2}, {"\ttab", 4}, {" \ttab", 4}, {"\t\t", 8}, {" ", 4},
|
||||
} {
|
||||
if got := leadingIndent(tc.line); got != tc.want {
|
||||
t.Errorf("leadingIndent(%q) = %d, want %d", tc.line, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fenceMarkerOf requires three or more markers", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
ch byte
|
||||
n int
|
||||
ok bool
|
||||
isOpe bool
|
||||
}{
|
||||
{"```", '`', 3, true, true},
|
||||
{"```go", '`', 3, true, true},
|
||||
{"````", '`', 4, true, true},
|
||||
{"~~~", '~', 3, true, true},
|
||||
{"~~~ yaml", '~', 3, true, true},
|
||||
{"``", 0, 0, false, false}, // too short to fence
|
||||
{"`code`", 0, 0, false, false}, // inline code
|
||||
{"text", 0, 0, false, false}, // not a marker at all
|
||||
{"", 0, 0, false, false}, // blank line inside a fence
|
||||
{"```a`b", '`', 3, true, false}, // backtick in the info string
|
||||
} {
|
||||
ch, n, ok := fenceMarkerOf(tc.line)
|
||||
if ch != tc.ch || n != tc.n || ok != tc.ok {
|
||||
t.Errorf("fenceMarkerOf(%q) = %q,%d,%v want %q,%d,%v", tc.line, ch, n, ok, tc.ch, tc.n, tc.ok)
|
||||
}
|
||||
if got := isFenceOpen(tc.line); got != tc.isOpe {
|
||||
t.Errorf("isFenceOpen(%q) = %v, want %v", tc.line, got, tc.isOpe)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("closingFenceIsBare rejects a trailing info string", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
ch byte
|
||||
n int
|
||||
want bool
|
||||
}{
|
||||
{"```", '`', 3, true},
|
||||
{"``` ", '`', 3, true},
|
||||
{"`````", '`', 3, true}, // a longer run still closes
|
||||
{"```go", '`', 3, false},
|
||||
} {
|
||||
if got := closingFenceIsBare(tc.line, tc.ch, tc.n); got != tc.want {
|
||||
t.Errorf("closingFenceIsBare(%q) = %v, want %v", tc.line, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("isATXHeading", func(t *testing.T) {
|
||||
for line, want := range map[string]bool{
|
||||
"# h": true, "###### h": true, "#": true, "#hash": false,
|
||||
"####### too deep": false, "text": false, "": false, "#\tt": true,
|
||||
} {
|
||||
if got := isATXHeading(line); got != want {
|
||||
t.Errorf("isATXHeading(%q) = %v, want %v", line, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("isRunOf and isThematicBreak", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
runDash bool
|
||||
breakAst bool
|
||||
}{
|
||||
{"---", true, false},
|
||||
{"-", true, false},
|
||||
{"", false, false},
|
||||
{"-x-", false, false},
|
||||
{"***", false, true},
|
||||
{"* * *", false, true}, // interior spaces are allowed
|
||||
{"**", false, false}, // fewer than three
|
||||
{"*x*", false, false},
|
||||
} {
|
||||
if got := isRunOf(tc.line, '-'); got != tc.runDash {
|
||||
t.Errorf("isRunOf(%q,'-') = %v, want %v", tc.line, got, tc.runDash)
|
||||
}
|
||||
if got := isThematicBreak(tc.line, '*'); got != tc.breakAst {
|
||||
t.Errorf("isThematicBreak(%q,'*') = %v, want %v", tc.line, got, tc.breakAst)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("isTableDelimiterRow requires a pipe", func(t *testing.T) {
|
||||
for line, want := range map[string]bool{
|
||||
"|---|---|": true,
|
||||
"---|---": true,
|
||||
"| :-: | ---: |": true,
|
||||
"---": false, // a thematic break or setext underline, never a table
|
||||
"--": false,
|
||||
"| a | b |": false, // a header row, not a delimiter
|
||||
"|": false,
|
||||
"| |": false, // empty cell, and no dash
|
||||
"|---|x|": false, // one cell is not a run of dashes
|
||||
"|---| |": false, // one cell is empty
|
||||
"": false,
|
||||
} {
|
||||
if got := isTableDelimiterRow(line); got != want {
|
||||
t.Errorf("isTableDelimiterRow(%q) = %v, want %v", line, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tableCellCount", func(t *testing.T) {
|
||||
for line, want := range map[string]int{
|
||||
"| a | b |": 2, "a | b": 2, "| a |": 1, "no pipes": 0,
|
||||
} {
|
||||
if got := tableCellCount(line); got != want {
|
||||
t.Errorf("tableCellCount(%q) = %d, want %d", line, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("listMarkerOf", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
ordered bool
|
||||
contentCol int
|
||||
ok bool
|
||||
}{
|
||||
{"- item", false, 2, true},
|
||||
{"+ item", false, 2, true},
|
||||
{"* item", false, 2, true},
|
||||
{"1. item", true, 3, true},
|
||||
{"12) item", true, 4, true},
|
||||
{"-", false, 1, true}, // a marker with no content
|
||||
{"- wide", false, 2, true}, // more than four spaces resets to one
|
||||
{" - nested", false, 4, true}, // indentation is included
|
||||
{"-item", false, 0, false}, // no space after the marker
|
||||
{"1.item", false, 0, false}, // no space after the number
|
||||
{"1x item", false, 0, false}, // not a marker
|
||||
{"text", false, 0, false},
|
||||
{"", false, 0, false},
|
||||
{" ", false, 0, false}, // whitespace only
|
||||
} {
|
||||
ordered, col, ok := listMarkerOf(tc.line)
|
||||
if ordered != tc.ordered || ok != tc.ok || (ok && col != tc.contentCol) {
|
||||
t.Errorf("listMarkerOf(%q) = %v,%d,%v want %v,%d,%v",
|
||||
tc.line, ordered, col, ok, tc.ordered, tc.contentCol, tc.ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("listMarkerNumber", func(t *testing.T) {
|
||||
for line, want := range map[string]int{
|
||||
"1. a": 1, "7. a": 7, "12) a": 12, "- a": 0,
|
||||
} {
|
||||
if got := listMarkerNumber(line); got != want {
|
||||
t.Errorf("listMarkerNumber(%q) = %d, want %d", line, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("htmlBlockKind classifies the seven types", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
line string
|
||||
kind uint8
|
||||
ok bool
|
||||
}{
|
||||
{"<script>", 1, true},
|
||||
{"<PRE>", 1, true},
|
||||
{"<style>", 1, true},
|
||||
{"<textarea>", 1, true},
|
||||
{"<!-- comment", 2, true},
|
||||
{"<?php", 3, true},
|
||||
{"<!DOCTYPE html>", 4, true},
|
||||
{"<![CDATA[", 5, true},
|
||||
{"<div>", 6, true},
|
||||
{"</ul>", 6, true},
|
||||
{"<custom-tag>", 7, true},
|
||||
{"<1>", 0, false}, // a tag name may not start with a digit
|
||||
{"<>", 0, false}, // no tag name at all
|
||||
{"</>", 0, false}, // closing marker with no name
|
||||
{"text", 0, false},
|
||||
{"", 0, false},
|
||||
} {
|
||||
kind, ok := htmlBlockKind(tc.line)
|
||||
if kind != tc.kind || ok != tc.ok {
|
||||
t.Errorf("htmlBlockKind(%q) = %d,%v want %d,%v", tc.line, kind, ok, tc.kind, tc.ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("htmlCloser per type", func(t *testing.T) {
|
||||
for kind, want := range map[uint8]string{2: "-->", 3: "?>", 4: ">", 5: "]]>", 1: "</script>"} {
|
||||
if got := htmlCloser(kind); got != want {
|
||||
t.Errorf("htmlCloser(%d) = %q, want %q", kind, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("isLinkReferenceDefinition", func(t *testing.T) {
|
||||
for line, want := range map[string]bool{
|
||||
"[ref]: https://example.com": true, "[a]: x": true,
|
||||
"[]: x": false, "[ref] not a def": false, "text": false, "": false,
|
||||
} {
|
||||
if got := isLinkReferenceDefinition(line); got != want {
|
||||
t.Errorf("isLinkReferenceDefinition(%q) = %v, want %v", line, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trimTrailingWhitespaceEnd", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
content string
|
||||
end int
|
||||
want int
|
||||
}{
|
||||
{"abc\n\n", 5, 3}, {"abc", 3, 3}, {"\n\n\n", 3, 0}, {"a \t\n", 4, 1},
|
||||
} {
|
||||
if got := trimTrailingWhitespaceEnd(tc.content, tc.end); got != tc.want {
|
||||
t.Errorf("trimTrailingWhitespaceEnd(%q,%d) = %d, want %d", tc.content, tc.end, got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownScanRegionGrouping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
// wantKinds lists the region kind of each region, in order.
|
||||
wantKinds []regionKind
|
||||
}{
|
||||
{"unterminated fence swallows the tail", "before\n\n```go\nnever closed\nmore",
|
||||
[]regionKind{regionParagraph, regionFence}},
|
||||
{"indented fence is still a fence", " ```go\ncode\n ```",
|
||||
[]regionKind{regionFence}},
|
||||
{"quote ends at a heading", "> quoted\n# heading",
|
||||
[]regionKind{regionQuote, regionLeaf}},
|
||||
{"quote absorbs a lazy continuation", "> quoted\nlazy line\n\nafter",
|
||||
[]regionKind{regionQuote, regionParagraph}},
|
||||
{"setext beats a table delimiter", "heading\n---",
|
||||
[]regionKind{regionSetext}},
|
||||
{"delimiter row with a mismatched header is not a table", "para\n|---|---|---|",
|
||||
[]regionKind{regionParagraph}},
|
||||
{"table keeps rows that have no pipes", "| a | b |\n|---|---|\n| 1 | 2 |\nstray",
|
||||
[]regionKind{regionTable}},
|
||||
{"paragraph before a table is its own region", "intro\n| a | b |\n|---|---|\n| 1 | 2 |",
|
||||
[]regionKind{regionParagraph, regionTable}},
|
||||
{"list ends at an unindented paragraph", "- item\n\nplain paragraph",
|
||||
[]regionKind{regionList, regionParagraph}},
|
||||
{"list ends at an adjacent heading with no blank line", "- item\n# heading",
|
||||
[]regionKind{regionList, regionLeaf}},
|
||||
{"list ends at end of input after a blank line", "- item\n\n",
|
||||
[]regionKind{regionList}},
|
||||
{"list keeps an indented continuation across a blank line", "- item\n\n continued\n\nplain",
|
||||
[]regionKind{regionList, regionParagraph}},
|
||||
{"html type 6 ends at a blank line", "<div>\nbody\n\nafter",
|
||||
[]regionKind{regionHTML, regionParagraph}},
|
||||
{"html type 1 spans blank lines", "<script>\n\nstill script\n</script>\n\nafter",
|
||||
[]regionKind{regionHTML, regionParagraph}},
|
||||
// Each type-1 tag must end at its OWN closer, not only </script>; a
|
||||
// mis-terminated <pre>/<style>/<textarea> would swallow everything after it.
|
||||
{"html type 1 pre ends at its own closer", "<pre>\ncode\n</pre>\n\nafter",
|
||||
[]regionKind{regionHTML, regionParagraph}},
|
||||
{"html type 1 style ends at its own closer", "<style>\n.x{}\n</style>\n\nafter",
|
||||
[]regionKind{regionHTML, regionParagraph}},
|
||||
{"html type 1 textarea ends at its own closer", "<textarea>\nhi\n</textarea>\n\nafter",
|
||||
[]regionKind{regionHTML, regionParagraph}},
|
||||
{"html type 2 comment ends at its own terminator", "<!-- note -->\n\nafter",
|
||||
[]regionKind{regionHTML, regionParagraph}},
|
||||
{"html type 2 comment without a terminator runs to end of input", "<!--\nunclosed comment",
|
||||
[]regionKind{regionHTML}},
|
||||
{"indented code spans a blank line", " one\n\n two\n\nplain",
|
||||
[]regionKind{regionIndentedCode, regionParagraph}},
|
||||
{"link reference definition is a leaf", "[ref]: https://example.com\n\npara",
|
||||
[]regionKind{regionLeaf, regionParagraph}},
|
||||
{"dash rule with no paragraph above is a leaf", "---\n\npara",
|
||||
[]regionKind{regionLeaf, regionParagraph}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
scan := scanMarkdownStructure(tc.content)
|
||||
got := make([]regionKind, 0, len(scan.regions))
|
||||
for _, r := range scan.regions {
|
||||
got = append(got, r.kind)
|
||||
}
|
||||
if len(got) != len(tc.wantKinds) {
|
||||
t.Fatalf("regions = %v, want %v", got, tc.wantKinds)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.wantKinds[i] {
|
||||
t.Errorf("region %d = %d, want %d (all: %v)", i, got[i], tc.wantKinds[i], got)
|
||||
}
|
||||
}
|
||||
// Every line must belong to exactly the region that claims it, and
|
||||
// the regions must tile the non-blank lines in order.
|
||||
for i, r := range scan.regions {
|
||||
for k := r.firstLine; k <= r.lastLine; k++ {
|
||||
if scan.lines[k].region != int32(i) {
|
||||
t.Errorf("line %d claims region %d, want %d", k, scan.lines[k].region, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownScanHTMLRepairDetection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
repair bool
|
||||
closer string
|
||||
}{
|
||||
{"pre is repairable", "<pre>\na\nb\n</pre>", true, "</pre>"},
|
||||
{"style is repairable", "<style>\n.a{}\n.b{}\n</style>", true, "</style>"},
|
||||
{"textarea is repairable", "<textarea>\na\nb\n</textarea>", true, "</textarea>"},
|
||||
{"multi-line opening tag is repairable", "<pre\n class=\"c\">\na\nb\n</pre>", true, "</pre>"},
|
||||
{"script is never repaired", "<script>\na\nb\n</script>", false, ""},
|
||||
{"div is not repairable", "<div>\na\nb\n</div>", false, ""},
|
||||
{"single-line pre has no interior", "<pre>x</pre>\n\ntail", false, ""},
|
||||
{"unterminated pre is not repaired", "<pre>\na\nb", false, ""},
|
||||
{"pre without a closing '>' is not repaired", "<pre\nno gt ever here", false, ""},
|
||||
{"<presentation> is not <pre>", "<presentation>\na\n</presentation>", false, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
scan := scanMarkdownStructure(tc.content)
|
||||
var html *region
|
||||
for i := range scan.regions {
|
||||
if scan.regions[i].kind == regionHTML {
|
||||
html = &scan.regions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if html == nil {
|
||||
t.Fatalf("no HTML region in %q (regions=%d)", tc.content, len(scan.regions))
|
||||
}
|
||||
if html.htmlRepair != tc.repair {
|
||||
t.Errorf("htmlRepair = %v, want %v", html.htmlRepair, tc.repair)
|
||||
}
|
||||
if html.htmlRepair && html.htmlCloser != tc.closer {
|
||||
t.Errorf("htmlCloser = %q, want %q", html.htmlCloser, tc.closer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownScanInterruptsParagraph(t *testing.T) {
|
||||
// A construct that cannot interrupt a paragraph must not produce a safe
|
||||
// boundary, because splitting there changes how the text above it parses.
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
content string
|
||||
limit int
|
||||
wantSafe bool
|
||||
wantDegrad string
|
||||
}{
|
||||
{"ordered list starting at one interrupts", "para text\n1. item", 10, true, ""},
|
||||
// `7.` cannot interrupt a paragraph, so the marker line is paragraph
|
||||
// text and the boundary is a paragraph split, not a block boundary.
|
||||
{"ordered list starting past one does not", "para text\n7. item", 10, false, "paragraph_split"},
|
||||
{"unordered list interrupts", "para text\n- item", 10, true, ""},
|
||||
{"heading interrupts", "para text\n## head", 10, true, ""},
|
||||
// The limit must leave room for the whole fence in the second chunk;
|
||||
// otherwise the fence itself needs splitting and that is a separate case.
|
||||
{"fence interrupts", "para text\n```go\nx\n```", 12, true, ""},
|
||||
{"quote interrupts", "para text\n> quoted", 10, true, ""},
|
||||
{"html type 6 interrupts", "para text\n<div>x</div>", 13, true, ""},
|
||||
{"asterisk rule interrupts", "para text\n***", 10, true, ""},
|
||||
{"dash rule does not interrupt", "para text\n---", 10, false, ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := SplitMarkdownForAppend(tc.content, tc.limit)
|
||||
if tc.wantSafe && plan.Degraded() {
|
||||
t.Errorf("expected a clean split, got %v", degradationKinds(plan))
|
||||
}
|
||||
if tc.wantDegrad != "" {
|
||||
if got := strings.Join(degradationKinds(plan), ","); !strings.Contains(got, tc.wantDegrad) {
|
||||
t.Errorf("degradations = %q, want to contain %q", got, tc.wantDegrad)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,17 @@ func TestCrossPlatformCoverageChunkedWriteCoverage(t *testing.T) {
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
// Every fixture below scripts a fixed number of MCP calls, so it silently
|
||||
// depends on the splitter producing exactly two chunks. Assert that up front:
|
||||
// otherwise a chunk-count change surfaces as a script-exhaustion panic deep
|
||||
// inside a subtest rather than as a clear failure here.
|
||||
if got := len(SplitMarkdownForAppend("abcdef", 3).Chunks); got != 2 {
|
||||
t.Fatalf("fixtures assume 2 chunks for \"abcdef\" at limit 3, got %d", got)
|
||||
}
|
||||
if got := len(SplitMarkdownForAppend(strings.Repeat("x", 12000), 6000).Chunks); got != 2 {
|
||||
t.Fatalf("fixtures assume 2 chunks for 12000 runes at limit 6000, got %d", got)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
tool string
|
||||
@@ -205,7 +216,7 @@ func TestCrossPlatformCoverageChunkedWriteCoverage(t *testing.T) {
|
||||
cancel()
|
||||
ctx = cancelled
|
||||
}
|
||||
_, _, _, _ = chunkedWrite(ctx, tc.tool, tc.args, tc.markdown, "test", tc.chunkSize)
|
||||
_, _ = chunkedWrite(ctx, tc.tool, tc.args, tc.markdown, "test", tc.chunkSize)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// 家校/高校系列(家校通讯录、家校群、家校应用、家庭群、高校通讯录)是开源库
|
||||
// 显式维护的公开命令,不依赖生成式产品注册表(register_products.go)。
|
||||
// 其 MCP server 端点由运行时宿主按 serverID 解析。
|
||||
func init() {
|
||||
eduCommands := []struct {
|
||||
name string
|
||||
buildFn func() *cobra.Command
|
||||
}{
|
||||
{"edu-contact", newEduContactCommand},
|
||||
{"edu-group", newEduGroupCommand},
|
||||
{"edu-app", newEduAppCommand},
|
||||
{"edu-familygroup", newEduFamilyGroupCommand},
|
||||
{"college-contact", newCollegeContactCommand},
|
||||
}
|
||||
for _, c := range eduCommands {
|
||||
c := c
|
||||
RegisterPublic(func() Handler {
|
||||
return wukongHandler{name: c.name, buildFn: c.buildFn}
|
||||
})
|
||||
}
|
||||
}
|
||||
+31
-13
@@ -23,8 +23,8 @@ const (
|
||||
reportDingtalkOpenLinkDescription = "点击后打开钉钉客户端的日志详情页,可查看或修改刚创建的日志。"
|
||||
reportContentsMaxBytes = 10 * 1024 * 1024
|
||||
reportDispatchTemplateSuccessHint = "dws report template get --name <模板名> --format json"
|
||||
reportDispatchTemplateDetailHint = "dws report entry submit --template-id <templateId> --contents-file <tmp.json> --format json"
|
||||
reportDispatchCreateHint = "dws report template list --format json\n dws report template get --name <模板名> --format json\n dws report entry submit --template-id <templateId> --contents-file <tmp.json> --format json"
|
||||
reportDispatchTemplateDetailHint = "dws report entry submit --template-id <templateId> --contents-file <tmp.json> --to-user-ids <userId1>,<userId2> --format json"
|
||||
reportDispatchCreateHint = "dws report template list --format json\n dws report template get --name <模板名> --format json\n dws report entry submit --template-id <templateId> --contents-file <tmp.json> --to-user-ids <userId1>,<userId2> --format json"
|
||||
reportDispatchDetailHint = "dws report outbox list --cursor 0 --size 20 --format json\n dws report entry get --report-id <reportId> --format json"
|
||||
reportDispatchStatsHint = "dws report outbox list --cursor 0 --size 20 --format json\n dws report entry stats --report-id <reportId> --format json"
|
||||
reportDispatchListHint = "dws report inbox list --start \"YYYY-MM-DDT00:00:00+08:00\" --end \"YYYY-MM-DDT23:59:59+08:00\" --cursor 0 --size 20 --format json"
|
||||
@@ -279,14 +279,15 @@ func newReportCommand() *cobra.Command {
|
||||
Long: `按模版提交一份日报。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
|
||||
与远程 create_report 一致;可先通过 report template list / template get 取得 templateId 与控件定义。
|
||||
|
||||
--to-user-ids 必填:无接收人的提交服务端仍会返回成功,但日志实际对任何人都不可见,因此 dws 侧强制要求接收人。
|
||||
长内容(含中文换行 / Markdown)建议走 --contents-file 避免 shell 引号问题;
|
||||
也可用 --contents - 从 stdin 读取。
|
||||
提交成功后会自动反查详情,并在返回中追加 dingtalkOpenUrl / dingtalkOpenMarkdownLink 跳转链接字段。`,
|
||||
Example: ` dws report entry submit --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
|
||||
Example: ` dws report entry submit --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]' --to-user-ids userId1
|
||||
# 推荐:长内容走文件
|
||||
dws report entry submit --template-id TPL_ID --contents-file ./report.json
|
||||
dws report entry submit --template-id TPL_ID --contents-file ./report.json --to-user-ids userId1,userId2
|
||||
# 或 stdin
|
||||
cat report.json | dws report entry submit --template-id TPL_ID --contents -
|
||||
cat report.json | dws report entry submit --template-id TPL_ID --contents - --to-user-ids userId1
|
||||
dws report entry submit --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
|
||||
RunE: runReportCreate,
|
||||
}
|
||||
@@ -312,14 +313,17 @@ func newReportCommand() *cobra.Command {
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按模版提交一份新日报",
|
||||
UseWhen: []string{"已取得 templateId 与字段定义,需要按模版提交日报/周报(contents[].key 必须等于模板 field_name)时"},
|
||||
UseWhen: []string{
|
||||
"已取得 templateId 与字段定义,需要按模版提交日报/周报(contents[].key 必须等于模板 field_name)时",
|
||||
"提交时必须通过 --to-user-ids 指定至少一个接收人;无接收人的日志提交后对任何人都不可见",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"尚未读取模板字段时先用 dws report template list / template get",
|
||||
"只需查看已有日志正文时改用 dws report entry get",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws report entry submit --template-id <templateId> --contents-file ./report.json --format json",
|
||||
"dws report entry submit --template-id <templateId> --contents '[{\"key\":\"今日完成\",\"sort\":\"0\",\"content\":\"完成了需求评审\",\"contentType\":\"markdown\",\"type\":\"1\"}]' --format json",
|
||||
"dws report entry submit --template-id <templateId> --contents-file ./report.json --to-user-ids <userId1>,<userId2> --format json",
|
||||
"dws report entry submit --template-id <templateId> --contents '[{\"key\":\"今日完成\",\"sort\":\"0\",\"content\":\"完成了需求评审\",\"contentType\":\"markdown\",\"type\":\"1\"}]' --to-user-ids <userId1> --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
@@ -448,7 +452,7 @@ func newReportCommand() *cobra.Command {
|
||||
createCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "[deprecated] 已废弃,请改用 `dws report entry submit`",
|
||||
Example: ` dws report create --template-id TPL_ID --contents-file ./report.json`,
|
||||
Example: ` dws report create --template-id TPL_ID --contents-file ./report.json --to-user-ids userId1,userId2`,
|
||||
RunE: withReportDeprecationWarning("create", "entry submit", runReportCreate),
|
||||
}
|
||||
addReportCreateFlags(createCmd)
|
||||
@@ -596,14 +600,24 @@ func runReportCreate(cmd *cobra.Command, args []string) error {
|
||||
ddFrom = "dws"
|
||||
}
|
||||
toChat, _ := cmd.Flags().GetBool("to-chat")
|
||||
// Cobra required 只拦截 flag 未传,不拦截空值;create_report 对无接收人的
|
||||
// 提交仍返回成功,但日志对任何接收人都不可见,因此这里对解析后的空接收人
|
||||
// 列表同样 fail-closed。
|
||||
toUserIDs := parseReportUserIDs(mustGetFlag(cmd, "to-user-ids"))
|
||||
if len(toUserIDs) == 0 {
|
||||
return &CLIError{
|
||||
Code: CodeMissingParam,
|
||||
Message: "to-user-ids is required",
|
||||
Suggestion: "通过 --to-user-ids userId1,userId2 指定至少一个日志接收人;无接收人的提交服务端仍返回成功,但日志对任何人都不可见",
|
||||
Operation: "report.create",
|
||||
}
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"templateId": tplID,
|
||||
"contents": contents,
|
||||
"ddFrom": ddFrom,
|
||||
"toChat": toChat,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("to-user-ids"); v != "" {
|
||||
toolArgs["toUserIds"] = parseReportUserIDs(v)
|
||||
"toUserIds": toUserIDs,
|
||||
}
|
||||
return callReportCreateWithDetailURL(toolArgs)
|
||||
}
|
||||
@@ -765,7 +779,11 @@ func addReportCreateFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("contents-file", "", "从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容,避免 shell 引号转义;优先级:--contents-file > --contents - (stdin) > --contents '<json>')")
|
||||
cmd.Flags().String("dd-from", "dws", "创建来源标识")
|
||||
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
|
||||
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
|
||||
// 无接收人的 create_report 服务端仍返回成功但日志不可见;openAPI 历史参数
|
||||
// 保持可选,dws 侧强制必填(dws report entry submit 与废弃别名 report create
|
||||
// 共用本函数,两侧 required 标记保持一致)。
|
||||
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (必填);无接收人的日志提交后对任何人都不可见")
|
||||
_ = cmd.MarkFlagRequired("to-user-ids")
|
||||
}
|
||||
|
||||
// withReportDeprecationWarning 包装旧命令的 RunE:调用时往 stderr 打废弃提醒,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// reportCreateArgsRecorder 记录 create_report 收到的原始参数,
|
||||
// 用于验证 runReportCreate 对 --to-user-ids 的解析与传递。
|
||||
type reportCreateArgsRecorder struct {
|
||||
response string
|
||||
format string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *reportCreateArgsRecorder) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if tool == "create_report" {
|
||||
c.args = args
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.response}}}, nil
|
||||
}
|
||||
|
||||
func (c *reportCreateArgsRecorder) Format() string { return c.format }
|
||||
func (*reportCreateArgsRecorder) DryRun() bool { return false }
|
||||
func (*reportCreateArgsRecorder) Fields() string { return "" }
|
||||
func (*reportCreateArgsRecorder) JQ() string { return "" }
|
||||
|
||||
func TestReportEntrySubmitRequiresRecipientFlag(t *testing.T) {
|
||||
t.Cleanup(func() { contract.ClearProductDeclForTest("report") })
|
||||
root := newReportCommand()
|
||||
// 主命令与废弃别名共用 addReportCreateFlags,两侧的 Cobra required
|
||||
// 标记必须同时存在(NativeRequired 一致性校验要求严格相等)。
|
||||
for _, path := range [][]string{{"entry", "submit"}, {"create"}} {
|
||||
leaf, _, err := root.Find(path)
|
||||
if err != nil || leaf == nil {
|
||||
t.Fatalf("%v command missing: %v", path, err)
|
||||
}
|
||||
flag := leaf.Flags().Lookup("to-user-ids")
|
||||
if flag == nil {
|
||||
t.Fatalf("%v missing --to-user-ids", path)
|
||||
}
|
||||
if _, required := flag.Annotations[cobra.BashCompOneRequiredFlag]; !required {
|
||||
t.Fatalf("%v --to-user-ids must stay Cobra required", path)
|
||||
}
|
||||
}
|
||||
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
// Cobra 的 Execute 会跳回 Root 执行,必须携带完整命令路径;
|
||||
// required 校验在 RunE 之前拦截未传的 --to-user-ids。
|
||||
root.SetArgs([]string{
|
||||
"entry", "submit",
|
||||
"--template-id", "TPL",
|
||||
"--contents", `[{"key":"k","sort":"0","content":"c","contentType":"markdown","type":"1"}]`,
|
||||
})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "to-user-ids") {
|
||||
t.Fatalf("missing --to-user-ids error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCreateRejectsBlankRecipient(t *testing.T) {
|
||||
t.Cleanup(func() { contract.ClearProductDeclForTest("report") })
|
||||
root := newReportCommand()
|
||||
submit, _, _ := root.Find([]string{"entry", "submit"})
|
||||
_ = submit.Flags().Set("template-id", "TPL")
|
||||
_ = submit.Flags().Set("contents", `[{"key":"k","sort":"0","content":"c","contentType":"markdown","type":"1"}]`)
|
||||
// Cobra required 只拦未传;空值/纯分隔符仍会进入 RunE,必须 fail-closed。
|
||||
_ = submit.Flags().Set("to-user-ids", " , ")
|
||||
err := runReportCreate(submit, nil)
|
||||
cliErr, ok := err.(*CLIError)
|
||||
if !ok || cliErr.Code != CodeMissingParam {
|
||||
t.Fatalf("blank to-user-ids error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCreatePassesRecipientsToCreateReport(t *testing.T) {
|
||||
t.Cleanup(func() { contract.ClearProductDeclForTest("report") })
|
||||
recorder := &reportCreateArgsRecorder{
|
||||
format: "json",
|
||||
response: `{"reportId":"id","url":"dingtalk://direct"}`,
|
||||
}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(recorder)
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = errOut
|
||||
|
||||
root := newReportCommand()
|
||||
submit, _, _ := root.Find([]string{"entry", "submit"})
|
||||
_ = submit.Flags().Set("template-id", "TPL")
|
||||
_ = submit.Flags().Set("contents", `[{"key":"k","sort":"0","content":"c","contentType":"markdown","type":"1"}]`)
|
||||
_ = submit.Flags().Set("to-user-ids", " userA , userB ")
|
||||
if err := runReportCreate(submit, nil); err != nil {
|
||||
t.Fatalf("runReportCreate: %v", err)
|
||||
}
|
||||
got, _ := recorder.args["toUserIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "userA" || got[1] != "userB" {
|
||||
t.Fatalf("toUserIds = %#v", recorder.args["toUserIds"])
|
||||
}
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 156 条叶子命令(dry-run 模式),
|
||||
// 并打印每条命令对应的 MCP 工具名和参数。相当于在终端逐一执行 dws <cmd> --dry-run。
|
||||
func TestRunAllEduCommands(t *testing.T) {
|
||||
type cmdCase struct {
|
||||
product string
|
||||
args []string
|
||||
}
|
||||
|
||||
allCmds := []cmdCase{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-contact: 29 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// school (6)
|
||||
{"edu-contact", []string{"school", "roles"}},
|
||||
{"edu-contact", []string{"school", "structure"}},
|
||||
{"edu-contact", []string{"school", "periods"}},
|
||||
{"edu-contact", []string{"school", "type"}},
|
||||
{"edu-contact", []string{"school", "stats", "--statistics-type", "1"}},
|
||||
{"edu-contact", []string{"school", "class-list"}},
|
||||
// class (19)
|
||||
{"edu-contact", []string{"class", "detail", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "students", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "teachers", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "same-name", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "user-role", "--dept-id", "12345"}},
|
||||
{"edu-contact", []string{"class", "search-by-name", "--query-type", "student", "--name", "张三"}},
|
||||
{"edu-contact", []string{"class", "headmaster", "--class-name", "一年级1班"}},
|
||||
{"edu-contact", []string{"class", "search-by-teacher", "--name", "张老师"}},
|
||||
{"edu-contact", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"}},
|
||||
{"edu-contact", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--student-mobile", "13800138000"}},
|
||||
{"edu-contact", []string{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "小明"}},
|
||||
{"edu-contact", []string{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"}},
|
||||
{"edu-contact", []string{"class", "update-info", "--class-id", "123", "--nick", "火箭班"}},
|
||||
{"edu-contact", []string{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S001"}},
|
||||
{"edu-contact", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"}},
|
||||
{"edu-contact", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"}},
|
||||
{"edu-contact", []string{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"}},
|
||||
{"edu-contact", []string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"}},
|
||||
{"edu-contact", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"}},
|
||||
// family (2)
|
||||
{"edu-contact", []string{"family", "children"}},
|
||||
{"edu-contact", []string{"family", "parents"}},
|
||||
// teacher (2)
|
||||
{"edu-contact", []string{"teacher", "classes"}},
|
||||
{"edu-contact", []string{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"MATH","courseName":"数学"}]`}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-group: 14 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// student-group (7)
|
||||
{"edu-group", []string{"student-group", "info", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "exists", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "members", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "is-in", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "conversation", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "create", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"student-group", "disband", "--dept-id", "12345"}},
|
||||
// class-group (4)
|
||||
{"edu-group", []string{"class-group", "conversation-id", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"class-group", "conversation", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"class-group", "exists", "--dept-id", "12345"}},
|
||||
{"edu-group", []string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"}},
|
||||
// batch (3)
|
||||
{"edu-group", []string{"batch", "check-student-group", "--class-ids", "12345,67890"}},
|
||||
{"edu-group", []string{"batch", "get-class-groups", "--class-ids", "12345,67890"}},
|
||||
{"edu-group", []string{"batch", "create-student-groups"}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-app: 42 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// message (1)
|
||||
{"edu-app", []string{"message", "summary-list", "--class-id", "1", "--cid", "c1", "--target-role", "guardian", "--status", "0"}},
|
||||
// task (3)
|
||||
{"edu-app", []string{"task", "publish-list"}},
|
||||
{"edu-app", []string{"task", "all-list", "--biz-id", "1"}},
|
||||
{"edu-app", []string{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`}},
|
||||
// report (5)
|
||||
{"edu-app", []string{"report", "get", "--ids", "1001,1002"}},
|
||||
{"edu-app", []string{"report", "by-teacher"}},
|
||||
{"edu-app", []string{"report", "by-class", "--report-id", "1001", "--class-id", "12345"}},
|
||||
{"edu-app", []string{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1"}},
|
||||
{"edu-app", []string{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"}},
|
||||
// notice (7)
|
||||
{"edu-app", []string{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1"}},
|
||||
{"edu-app", []string{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假"}},
|
||||
{"edu-app", []string{"notice", "delete", "--notice-id", "12345"}},
|
||||
{"edu-app", []string{"notice", "list-by-teacher"}},
|
||||
{"edu-app", []string{"notice", "get", "--notice-id", "12345"}},
|
||||
{"edu-app", []string{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
|
||||
// circle (1)
|
||||
{"edu-app", []string{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"}},
|
||||
// card (5)
|
||||
{"edu-app", []string{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题"}},
|
||||
{"edu-app", []string{"card", "end", "--card-id", "1"}},
|
||||
{"edu-app", []string{"card", "list", "--status", "UNFINISH"}},
|
||||
{"edu-app", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1"}},
|
||||
{"edu-app", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1"}},
|
||||
// diploma (9)
|
||||
{"edu-app", []string{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "三好学生", "--user-name", "张三"}},
|
||||
{"edu-app", []string{"diploma", "read", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "list-by-teacher"}},
|
||||
{"edu-app", []string{"diploma", "get", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "statistics", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "detail", "--diploma-id", "1"}},
|
||||
{"edu-app", []string{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"diploma", "delete", "--diploma-id", "1"}},
|
||||
// homework (11)
|
||||
{"edu-app", []string{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习册第3页"}},
|
||||
{"edu-app", []string{"homework", "delete", "--homework-id", "1"}},
|
||||
{"edu-app", []string{"homework", "submit", "--hw-content-detail-id", "1"}},
|
||||
{"edu-app", []string{"homework", "get", "--homework-id", "1"}},
|
||||
{"edu-app", []string{"homework", "class-by-homework", "--homework-id", "1"}},
|
||||
{"edu-app", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c1", "--user-name", "张老师"}},
|
||||
{"edu-app", []string{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c1", "--user-name", "张三"}},
|
||||
{"edu-app", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c1"}},
|
||||
{"edu-app", []string{"homework", "list-by-teacher"}},
|
||||
{"edu-app", []string{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1"}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// edu-familygroup: 6 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// group (2)
|
||||
{"edu-familygroup", []string{"group", "check-exists", "--uid", "12345", "--group-name", "小明一家"}},
|
||||
{"edu-familygroup", []string{"group", "list-children", "--uid", "12345"}},
|
||||
// manage (4)
|
||||
{"edu-familygroup", []string{"manage", "create", "--uid", "12345", "--children", `[{"name":"小明","students":[{"corpId":"corp1","staffId":"staff1"}]}]`}},
|
||||
{"edu-familygroup", []string{"manage", "invite-parent", "--org-id", "12345", "--uid", "67890", "--mobile", "13800138000"}},
|
||||
{"edu-familygroup", []string{"manage", "add-child", "--org-id", "12345", "--uid", "67890", "--name", "小红", "--mobile", "13900139000"}},
|
||||
{"edu-familygroup", []string{"manage", "toggle-app", "--org-id", "12345", "--uid", "67890", "--child-staff-id", "staff1", "--app-type", "XIAOTIANDI", "--open", "true"}},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// college-contact: 65 commands
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// dept (9)
|
||||
{"college-contact", []string{"dept", "get-standard-structure"}},
|
||||
{"college-contact", []string{"dept", "get-detail", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"dept", "get-chain", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"dept", "search", "--dept-id", "12345", "--keyword", "计算机"}},
|
||||
{"college-contact", []string{"dept", "create", "--super-id", "1", "--stru-dept-id", "2", "--name", "计算机学院", "--dept-type", "COLLEGE", "--create-dept-group", "true"}},
|
||||
{"college-contact", []string{"dept", "update", "--dept-id", "12345", "--dept-type", "COLLEGE"}},
|
||||
{"college-contact", []string{"dept", "delete", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"dept", "batch-update-type", "--dept-ids", "1,2,3", "--target-dept-type", "COLLEGE"}},
|
||||
{"college-contact", []string{"dept", "overview"}},
|
||||
// employee (11)
|
||||
{"college-contact", []string{"employee", "get-detail", "--staff-id", "staff001"}},
|
||||
{"college-contact", []string{"employee", "add", "--emp-type", "TEACHER", "--main-dept-id", "12345", "--exclusive-account", "false"}},
|
||||
{"college-contact", []string{"employee", "remove", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"employee", "change-type", "--staff-id", "staff001", "--emp-type", "STUDENT"}},
|
||||
{"college-contact", []string{"employee", "change-dept", "--staff-id", "staff001", "--target-dept-id", "67890"}},
|
||||
{"college-contact", []string{"employee", "send-active-sms", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "list-employees", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "list-unaccepted", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "list-unactive", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"employee", "upgrade-status"}},
|
||||
{"college-contact", []string{"employee", "start-upgrade"}},
|
||||
// alumni (20)
|
||||
{"college-contact", []string{"alumni", "get-dept-tree", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "get-info", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "list", "--alumni-dept-id", "12345", "--order-field", "NAME", "--ordering", "ASC"}},
|
||||
{"college-contact", []string{"alumni", "query", "--staff-id", "staff001"}},
|
||||
{"college-contact", []string{"alumni", "search", "--keyword", "张三"}},
|
||||
{"college-contact", []string{"alumni", "list-unaccepted", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "get-group", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "create-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届"}},
|
||||
{"college-contact", []string{"alumni", "update-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届计算机"}},
|
||||
{"college-contact", []string{"alumni", "delete-dept", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "update-managers", "--alumni-dept-id", "12345", "--admin-user-ids", "u1,u2"}},
|
||||
{"college-contact", []string{"alumni", "add-alumnus", "--dept-ids", "1,2", "--name", "张三", "--mobile", "13800138000"}},
|
||||
{"college-contact", []string{"alumni", "update-alumnus", "--dept-ids", "1,2", "--staff-id", "s1", "--name", "张三"}},
|
||||
{"college-contact", []string{"alumni", "remove-alumnus", "--staff-id", "s1", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "cancel-invite", "--alumni-dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"alumni", "create-group", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "disband-group", "--alumni-dept-id", "12345"}},
|
||||
{"college-contact", []string{"alumni", "get-alumni-org-from-graduate"}},
|
||||
{"college-contact", []string{"alumni", "create-alumni-org", "--org-name", "计算机校友会"}},
|
||||
{"college-contact", []string{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"}},
|
||||
// graduate (16)
|
||||
{"college-contact", []string{"graduate", "query-graduate-years"}},
|
||||
{"college-contact", []string{"graduate", "query-graduate-depts", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "query-graduate-sub-depts", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "query-page-graduate-users", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "get-task-result", "--request-no", "req001"}},
|
||||
{"college-contact", []string{"graduate", "get-alumni-org"}},
|
||||
{"college-contact", []string{"graduate", "query-restore-sub-depts", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "query-dept-deleted-emps", "--dept-id", "12345"}},
|
||||
{"college-contact", []string{"graduate", "search-graduate", "--keyword", "张三"}},
|
||||
{"college-contact", []string{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}},
|
||||
{"college-contact", []string{"graduate", "all-graduate", "--graduate-year", "2026"}},
|
||||
{"college-contact", []string{"graduate", "batch-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"graduate", "delete-and-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"graduate", "batch-delete-pending", "--dept-id", "12345", "--staff-ids", "s1,s2"}},
|
||||
{"college-contact", []string{"graduate", "batch-update-pending", "--dept-id", "12345", "--staff-ids", "s1,s2", "--graduate-year", "2026"}},
|
||||
{"college-contact", []string{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"}},
|
||||
// group (9)
|
||||
{"college-contact", []string{"group", "query-group-rule"}},
|
||||
{"college-contact", []string{"group", "get-group-rule-schedule"}},
|
||||
{"college-contact", []string{"group", "query-preview-data"}},
|
||||
{"college-contact", []string{"group", "create-group-rule", "--name", "自动分组", "--tag-code", "TAG1", "--dept-type", "COLLEGE"}},
|
||||
{"college-contact", []string{"group", "delete-group-rule", "--rule-id", "1"}},
|
||||
{"college-contact", []string{"group", "enable-group-rule", "--rule-id", "1"}},
|
||||
{"college-contact", []string{"group", "disable-group-rule", "--rule-id", "1"}},
|
||||
{"college-contact", []string{"group", "set-group-rule-schedule"}},
|
||||
{"college-contact", []string{"group", "execute-group-rule"}},
|
||||
}
|
||||
|
||||
// 按产品分组的命令构建器
|
||||
builders := map[string]func() *cobra.Command{
|
||||
"edu-contact": newEduContactCommand,
|
||||
"edu-group": newEduGroupCommand,
|
||||
"edu-app": newEduAppCommand,
|
||||
"edu-familygroup": newEduFamilyGroupCommand,
|
||||
"college-contact": newCollegeContactCommand,
|
||||
}
|
||||
|
||||
// 安装 dry-run caller
|
||||
caller := &recruitCaptureCaller{dryRun: true}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
passed := 0
|
||||
failed := 0
|
||||
currentProduct := ""
|
||||
|
||||
for i, c := range allCmds {
|
||||
if c.product != currentProduct {
|
||||
currentProduct = c.product
|
||||
fmt.Printf("\n══════════════════════════════════════════════════════\n")
|
||||
fmt.Printf(" %s\n", strings.ToUpper(currentProduct))
|
||||
fmt.Printf("══════════════════════════════════════════════════════\n")
|
||||
}
|
||||
|
||||
buildFn, ok := builders[c.product]
|
||||
if !ok {
|
||||
t.Fatalf("unknown product: %s", c.product)
|
||||
}
|
||||
root := buildFn()
|
||||
root.SetArgs(c.args)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
|
||||
// Reset caller capture
|
||||
caller.productID = ""
|
||||
caller.tool = ""
|
||||
caller.args = nil
|
||||
|
||||
err := root.Execute()
|
||||
cmdPath := fmt.Sprintf("dws %s %s", c.product, strings.Join(c.args, " "))
|
||||
|
||||
if err != nil {
|
||||
failed++
|
||||
fmt.Printf(" [%3d] ✗ FAIL: %s\n", i+1, cmdPath)
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
t.Errorf("command %d failed: %s → %v", i+1, cmdPath, err)
|
||||
} else {
|
||||
passed++
|
||||
fmt.Printf(" [%3d] ✓ %s\n", i+1, cmdPath)
|
||||
if caller.tool != "" {
|
||||
fmt.Printf(" → MCP: %s.%s\n", caller.productID, caller.tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n══════════════════════════════════════════════════════\n")
|
||||
fmt.Printf(" SUMMARY: %d passed, %d failed, %d total\n", passed, failed, passed+failed)
|
||||
fmt.Printf("══════════════════════════════════════════════════════\n")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Relocated verbatim from content_splitter_test.go, which was deleted along with
|
||||
// the block-rebuilding splitter. This test targets runtime_defaults.go and has
|
||||
// nothing to do with markdown chunking.
|
||||
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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
@@ -35,8 +34,7 @@ type downloadTempFile interface {
|
||||
|
||||
var (
|
||||
createDownloadTemp = createDownloadTempInRoot
|
||||
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
|
||||
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
secureDialContext = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
localGetwd = os.Getwd
|
||||
localAbs = filepath.Abs
|
||||
localEvalSymlinks = filepath.EvalSymlinks
|
||||
@@ -292,53 +290,39 @@ func SafeFilename(preferredName, rawURL string) string {
|
||||
return "download"
|
||||
}
|
||||
|
||||
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
|
||||
// ValidateDownloadURL accepts HTTPS download URLs on any host and port, IP
|
||||
// literals included — mirroring the official GUI client, which applies no
|
||||
// client-side SSRF interception to downloads. Only userinfo URLs stay
|
||||
// rejected; TLS hostname verification in secureHTTPClient pins the
|
||||
// connection to the requested host and redirects are re-validated per hop.
|
||||
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
||||
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
|
||||
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
|
||||
}
|
||||
if port := parsed.Port(); port != "" && port != "443" {
|
||||
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
|
||||
return nil, fmt.Errorf("下载地址必须是合法的 HTTPS URL")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// SecureHTTPClient returns a download client enforcing the same URL policy
|
||||
// and redirect hygiene as Download. Product shortcuts that own their
|
||||
// local-file workflow (e.g. chat message resources) share it so download URL
|
||||
// trust decisions stay in one place.
|
||||
func SecureHTTPClient() *http.Client {
|
||||
return secureHTTPClient()
|
||||
}
|
||||
|
||||
func secureHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
// Do not use environment proxies here. DialContext must resolve and dial
|
||||
// the validated download host itself; with a proxy it would receive the
|
||||
// proxy address and could not enforce the target host's public-IP policy.
|
||||
// Dial the service-issued host directly, ignoring environment proxies:
|
||||
// dedicated-deployment storage may live on customer intranets that are
|
||||
// only reachable without a proxy, and TLS hostname verification always
|
||||
// runs against the requested host. Download URLs never come from
|
||||
// user input — every command resolves them through an authenticated
|
||||
// MCP response first, mirroring the official GUI client which applies
|
||||
// no client-side SSRF interception to downloads.
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := lookupDownloadIPs(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resolved := range ips {
|
||||
if !publicIP(resolved.IP) {
|
||||
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
|
||||
}
|
||||
}
|
||||
// Dial the already validated address, not the hostname, to avoid a
|
||||
// second DNS lookup opening a rebinding window.
|
||||
var lastErr error
|
||||
for _, resolved := range ips {
|
||||
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
return secureDialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
|
||||
@@ -373,39 +357,6 @@ func downloadOrigin(parsed *url.URL) string {
|
||||
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func allowedDownloadHost(host string) bool {
|
||||
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
|
||||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
|
||||
}
|
||||
|
||||
func publicIP(ip net.IP) bool {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range nonPublicPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var nonPublicPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
|
||||
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
|
||||
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
|
||||
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
|
||||
netip.MustParsePrefix("240.0.0.0/4"), // reserved
|
||||
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
|
||||
}
|
||||
|
||||
func ensureSafeParent(root *os.Root, parent string) error {
|
||||
if parent == "." {
|
||||
return nil
|
||||
|
||||
@@ -60,10 +60,20 @@ func (f *coverageTempFile) Close() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
|
||||
func TestCrossPlatformCoverageDownloadURLPolicy(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://alidocs.dingtalk.com/file.docx",
|
||||
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
|
||||
// 域名白名单、IP 直连拦截与拨号层公网 IP 校验均已移除:
|
||||
// 任意 HTTPS 主机(含 IP 字面量)在 URL 校验层放行,对齐
|
||||
// GUI 客户端(无客户端侧 SSRF 拦截)。
|
||||
"https://ddoss.ijingbo.chambroad.com/file.doc",
|
||||
// 专属部署存储域可服务在非默认端口;端口不是信任信号。
|
||||
"https://ddoss.ijingbo.chambroad.com:8443/file.doc",
|
||||
"https://evil.example/file.docx",
|
||||
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
|
||||
"https://127.0.0.1/file.docx",
|
||||
"https://[::1]/file.docx",
|
||||
}
|
||||
for _, raw := range valid {
|
||||
if _, err := ValidateDownloadURL(raw); err != nil {
|
||||
@@ -72,28 +82,15 @@ func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
|
||||
}
|
||||
invalid := []string{
|
||||
"http://alidocs.dingtalk.com/file.docx",
|
||||
"https://127.0.0.1/file.docx",
|
||||
"https://evil.example/file.docx",
|
||||
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
|
||||
"https://user@alidocs.dingtalk.com/file.docx",
|
||||
"https://alidocs.dingtalk.com:8443/file.docx",
|
||||
"http://alidocs.dingtalk.com:8443/file.docx",
|
||||
"https://alidocs.dingtalk.com:NOTAPORT/file.docx",
|
||||
}
|
||||
for _, raw := range invalid {
|
||||
if _, err := ValidateDownloadURL(raw); err == nil {
|
||||
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} {
|
||||
if publicIP(net.ParseIP(raw)) {
|
||||
t.Errorf("publicIP(%s) = true", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
|
||||
if !publicIP(net.ParseIP(raw)) {
|
||||
t.Errorf("publicIP(%s) = false", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
|
||||
@@ -363,7 +360,7 @@ func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil {
|
||||
t.Fatal("redirect limit accepted")
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil {
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "http://evil.example/x")}, nil); err == nil {
|
||||
t.Fatal("unsafe redirect accepted")
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil {
|
||||
@@ -373,49 +370,22 @@ func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
|
||||
t.Fatal("bad address dial succeeded")
|
||||
}
|
||||
|
||||
t.Run("lookup error", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
|
||||
t.Run("dial seam failure propagates", func(t *testing.T) {
|
||||
testseam.Swap(t, &secureDialContext, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("lookup error ignored")
|
||||
t.Fatal("dial failure ignored")
|
||||
}
|
||||
})
|
||||
t.Run("private answer", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
|
||||
})
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("private DNS answer accepted")
|
||||
}
|
||||
})
|
||||
t.Run("public dial fallback and success", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
|
||||
})
|
||||
t.Run("dial seam success", func(t *testing.T) {
|
||||
left, right := net.Pipe()
|
||||
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
|
||||
calls := 0
|
||||
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errors.New("first")
|
||||
}
|
||||
return left, nil
|
||||
})
|
||||
testseam.Swap(t, &secureDialContext, func(context.Context, string, string) (net.Conn, error) { return left, nil })
|
||||
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
t.Run("all public dials fail", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
|
||||
})
|
||||
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("dial failure ignored")
|
||||
}
|
||||
})
|
||||
|
||||
base := t.TempDir()
|
||||
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
|
||||
@@ -504,17 +474,43 @@ func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
|
||||
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
|
||||
_ = SafeFilename("", "https://download.dingtalk.com/%zz")
|
||||
_ = SafeFilename("", "://bad")
|
||||
_ = publicIP(net.IP{1, 2, 3})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
|
||||
transport := secureHTTPClient().Transport.(*http.Transport)
|
||||
transport := SecureHTTPClient().Transport.(*http.Transport)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("secure download client accepted an environment proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSetSecureDownloadDialTargetForTest(t *testing.T) {
|
||||
prevDial := secureDialContext
|
||||
t.Cleanup(func() { secureDialContext = prevDial })
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = listener.Close() })
|
||||
accepted := make(chan struct{})
|
||||
go func() {
|
||||
conn, acceptErr := listener.Accept()
|
||||
if acceptErr == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
close(accepted)
|
||||
}()
|
||||
|
||||
SetSecureDownloadDialTargetForTest(listener.Addr().String())
|
||||
conn, err := secureDialContext(context.Background(), "tcp", "download.dingtalk.com:443")
|
||||
if err != nil {
|
||||
t.Fatalf("overridden dial did not reach the fixture listener: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
<-accepted
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) {
|
||||
client := secureHTTPClient()
|
||||
original := &http.Request{
|
||||
@@ -536,6 +532,17 @@ func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testin
|
||||
t.Fatal("same-origin redirect unexpectedly stripped request headers")
|
||||
}
|
||||
|
||||
sameHostNonDefaultPort := &http.Request{
|
||||
URL: mustURL(t, "https://download.dingtalk.com:8443/next"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(sameHostNonDefaultPort, []*http.Request{original}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sameHostNonDefaultPort.Header) != 0 {
|
||||
t.Fatal("port change is a cross-origin redirect; headers must be stripped")
|
||||
}
|
||||
|
||||
crossOrigin := &http.Request{
|
||||
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"),
|
||||
Header: original.Header.Clone(),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// SetSecureDownloadDialTargetForTest reroutes every secure download dial to
|
||||
// fixtureAddr. Subprocess e2e tests use it to serve platform download hosts
|
||||
// from a loopback TLS fixture: TLS SNI and certificate verification still run
|
||||
// against the real host name, and the production client keeps Proxy disabled.
|
||||
// Production code must not call this.
|
||||
func SetSecureDownloadDialTargetForTest(fixtureAddr string) {
|
||||
secureDialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, fixtureAddr)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -17,13 +18,39 @@ const defaultUploadLimit = int64(5 << 30)
|
||||
var (
|
||||
newUploadHTTPClient = secureHTTPClient
|
||||
validateUploadURL = func(raw string) error {
|
||||
_, err := ValidateDownloadURL(raw)
|
||||
return err
|
||||
parsed, err := ValidateDownloadURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Uploads publish local file bytes outward, so unlike host-agnostic
|
||||
// downloads the target keeps the pre-existing DingTalk/OSS trust set.
|
||||
if !trustedUploadHost(parsed.Hostname()) {
|
||||
return fmt.Errorf("上传地址域名 %q 不属于受信任的钉钉或 OSS 域名", parsed.Hostname())
|
||||
}
|
||||
// Ditto for the port: DingTalk/OSS upload endpoints always serve HTTPS
|
||||
// on the default port, so a non-default port is anomalous for uploads
|
||||
// even though dedicated-deployment download domains legitimately use
|
||||
// one. Keep the upload boundary identical to the pre-removal policy.
|
||||
if port := parsed.Port(); port != "" && port != "443" {
|
||||
return fmt.Errorf("上传地址只允许 HTTPS 默认端口")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
openUploadFile = os.Open
|
||||
newUploadRequest = http.NewRequestWithContext
|
||||
)
|
||||
|
||||
// trustedUploadHost keeps the upload host trust boundary that existed before
|
||||
// the download allowlist removal: public DingTalk and Aliyun OSS domains
|
||||
// only. Downloads may be host-agnostic because their URL and credential
|
||||
// headers are issued together by the authenticated service response, but
|
||||
// uploads send local file bytes outward and stay statically bounded.
|
||||
func trustedUploadHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
|
||||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
|
||||
}
|
||||
|
||||
// UploadResult records only non-sensitive transfer facts. The signed URL is
|
||||
// deliberately never returned.
|
||||
type UploadResult struct {
|
||||
|
||||
@@ -215,3 +215,33 @@ func TestCrossPlatformCoveragePutFileRejectsRedirectAndInvalidFileE2E(t *testing
|
||||
t.Fatal("missing file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUploadURLTrustPolicy(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"https://upload.dingtalk.com/x",
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/key",
|
||||
"https://upload.dingtalk.com:443/x",
|
||||
} {
|
||||
if err := validateUploadURL(raw); err != nil {
|
||||
t.Errorf("validateUploadURL(%q): %v", raw, err)
|
||||
}
|
||||
}
|
||||
// 上传将本地文件字节向外发布,保持白名单移除前的静态可信域名与端口边界。
|
||||
// 下载侧为专属部署非标端口放行的端口不适用于上传:钉钉/OSS 上传端点恒为 443。
|
||||
for _, raw := range []string{
|
||||
"http://upload.dingtalk.com/x",
|
||||
"https://evil.example/x",
|
||||
"https://ddoss.ijingbo.chambroad.com/x",
|
||||
"https://ddoss.ijingbo.chambroad.com:8443/x",
|
||||
"https://bucket.oss-cn-hangzhou-internal.aliyuncs.com/key",
|
||||
"https://evildingtalk.com/x",
|
||||
"https://upload.dingtalk.com:8443/x",
|
||||
} {
|
||||
if err := validateUploadURL(raw); err == nil {
|
||||
t.Errorf("validateUploadURL(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
if !trustedUploadHost("download.dingtalk.com.") {
|
||||
t.Error("trailing-dot trusted host rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,18 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const resourceDownloadTimeout = 10 * time.Minute
|
||||
|
||||
var (
|
||||
resourceGetwd = os.Getwd
|
||||
resourceAbs = filepath.Abs
|
||||
@@ -48,6 +45,7 @@ var (
|
||||
resourceRename = replaceFileAtomically
|
||||
resourceLink = os.Link
|
||||
resourceDownload = downloadResourceAtomically
|
||||
resourceSecureClient = localio.SecureHTTPClient
|
||||
)
|
||||
|
||||
// MessagesResourceDownload resolves a temporary IM resource URL and saves the
|
||||
@@ -318,35 +316,21 @@ func isAliyunOSSHost(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// validateResourceDownloadURL enforces the shared localio download URL
|
||||
// policy: HTTPS URLs without userinfo, on any host or port, IP literals
|
||||
// included. Resource URLs are always resolved from an authenticated MCP
|
||||
// response (never user-supplied), and localio.SecureHTTPClient keeps TLS
|
||||
// hostname verification plus redirect credential hygiene, mirroring the
|
||||
// official GUI client which applies no client-side SSRF interception to
|
||||
// downloads.
|
||||
func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil ||
|
||||
parsed.Scheme != "https" ||
|
||||
strings.TrimSpace(parsed.Host) == "" ||
|
||||
parsed.User != nil {
|
||||
return nil, apperrors.NewValidation("资源下载地址必须是受信任域名上的 HTTPS URL")
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), "."))
|
||||
if host == "" || net.ParseIP(host) != nil || !isResourceDownloadAllowedHost(host) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"资源下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host))
|
||||
}
|
||||
if port := parsed.Port(); port != "" && port != "443" {
|
||||
return nil, apperrors.NewValidation("资源下载地址只允许使用 HTTPS 默认端口")
|
||||
parsed, err := localio.ValidateDownloadURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(err.Error())
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func isResourceDownloadAllowedHost(host string) bool {
|
||||
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
|
||||
// Lower tools may return signed headers with the URL. Keep those headers
|
||||
// confined to platform-owned public download families; extend this list
|
||||
// only after observing another official production download host.
|
||||
return isAliyunOSSHost(host) ||
|
||||
host == "dingtalk.com" ||
|
||||
strings.HasSuffix(host, ".dingtalk.com")
|
||||
}
|
||||
|
||||
func resolveResourceDownloadPath(
|
||||
baseDir, output, resourceURL string,
|
||||
overwrite bool,
|
||||
@@ -515,12 +499,17 @@ func downloadResourceAtomically(
|
||||
overwrite bool,
|
||||
) (size int64, err error) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: resourceDownloadTimeout}
|
||||
client = resourceSecureClient()
|
||||
}
|
||||
parsedResourceURL, err := validateResourceDownloadURL(resourceURL)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// The resource URL and any credential headers are issued together by the
|
||||
// same authenticated MCP response, so the initial request forwards them
|
||||
// as-is even for dedicated-deployment storage hosts. The attack surface
|
||||
// is a redirect leaving the original host, which the header-stripping
|
||||
// CheckRedirect below covers.
|
||||
clientCopy := *client
|
||||
client = &clientCopy
|
||||
originalRedirect := client.CheckRedirect
|
||||
|
||||
@@ -33,6 +33,7 @@ func resetResourceDownloadHooks(t *testing.T) {
|
||||
renameFn := resourceRename
|
||||
linkFn := resourceLink
|
||||
downloadFn := resourceDownload
|
||||
secureClientFn := resourceSecureClient
|
||||
t.Cleanup(func() {
|
||||
resourceGetwd = getwd
|
||||
resourceAbs = abs
|
||||
@@ -48,6 +49,7 @@ func resetResourceDownloadHooks(t *testing.T) {
|
||||
resourceRename = renameFn
|
||||
resourceLink = linkFn
|
||||
resourceDownload = downloadFn
|
||||
resourceSecureClient = secureClientFn
|
||||
})
|
||||
}
|
||||
|
||||
@@ -186,16 +188,23 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
|
||||
t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want)
|
||||
}
|
||||
}
|
||||
for host, want := range map[string]bool{
|
||||
"DINGTALK.COM.": true,
|
||||
"download.dingtalk.com": true,
|
||||
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
|
||||
"aliyuncs.com.evil.test": false,
|
||||
"evildingtalk.com": false,
|
||||
"download.example.invalid": false,
|
||||
// Host trust is no longer a static allowlist: any HTTPS host (including
|
||||
// dedicated-deployment download hosts and IP literals) passes URL
|
||||
// validation, while userinfo URLs and plain HTTP stay rejected.
|
||||
// Non-default HTTPS ports are accepted: dedicated storage domains
|
||||
// legitimately serve on them.
|
||||
for rawURL, wantOK := range map[string]bool{
|
||||
"https://download.dingtalk.com/file": true,
|
||||
"https://bucket.oss-cn-hangzhou.aliyuncs.com/file": true,
|
||||
"https://ddoss.tenant.example.com/file": true,
|
||||
"https://ddoss.tenant.example.com:8443/file": true,
|
||||
"https://203.0.113.5/file": true,
|
||||
"http://download.dingtalk.com/file": false,
|
||||
"http://download.dingtalk.com:8443/file": false,
|
||||
"https://user:secret@download.dingtalk.com/file": false,
|
||||
} {
|
||||
if got := isResourceDownloadAllowedHost(host); got != want {
|
||||
t.Errorf("isResourceDownloadAllowedHost(%q) = %v, want %v", host, got, want)
|
||||
if _, err := validateResourceDownloadURL(rawURL); (err == nil) != wantOK {
|
||||
t.Errorf("validateResourceDownloadURL(%q) error = %v, want ok=%v", rawURL, err, wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,12 +426,55 @@ func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) {
|
||||
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
|
||||
t.Fatal("content-length mismatch was accepted")
|
||||
}
|
||||
nilClientDest := filepath.Join(t.TempDir(), "nil-client")
|
||||
if _, err := downloadResourceAtomically(
|
||||
context.Background(), nil, "https://evil.example/file",
|
||||
map[string]string{"X-Test": "ok"}, nilClientDest, true,
|
||||
); err == nil {
|
||||
t.Fatal("nil-client path accepted an untrusted URL")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadResourceDedicatedHostWithHeaders(t *testing.T) {
|
||||
// 专属部署域名 + 服务端凭据头是真实生产场景(部分专属大客):
|
||||
// URL 与凭据头由同一已认证 MCP 响应成对下发,首跳按原样转发,
|
||||
// 不得因域名不在静态可信集而拒绝或剥离。
|
||||
for _, headers := range []map[string]string{
|
||||
{"Authorization": "signed"},
|
||||
nil,
|
||||
} {
|
||||
dest := filepath.Join(t.TempDir(), "resource")
|
||||
if _, err := downloadResourceAtomically(
|
||||
context.Background(), resourceResponseClient(200, "ok", 2),
|
||||
"https://ddoss.ijingbo.chambroad.com/file", headers, dest, false,
|
||||
); err != nil {
|
||||
t.Fatalf("dedicated host download (headers=%v) = %v", headers, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadResourceNilClientUsesSecureDefault(t *testing.T) {
|
||||
resetResourceDownloadHooks(t)
|
||||
served := false
|
||||
resourceSecureClient = func() *http.Client {
|
||||
return &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
|
||||
served = true
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
ContentLength: 2,
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
}
|
||||
// IP-literal download hosts pass the same host-agnostic HTTPS policy as
|
||||
// domain hosts: the GUI client applies no client-side SSRF interception.
|
||||
for _, resourceURL := range []string{
|
||||
"https://download.dingtalk.com/file",
|
||||
"https://203.0.113.5/file",
|
||||
} {
|
||||
if _, err := downloadResourceAtomically(
|
||||
context.Background(), nil, resourceURL, nil,
|
||||
filepath.Join(t.TempDir(), "nil-secure"), false,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !served {
|
||||
t.Fatal("nil client did not route through the secure default client")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,11 +81,8 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
|
||||
t.Fatal("plain HTTP URL unexpectedly accepted")
|
||||
}
|
||||
for _, resourceURL := range []string{
|
||||
"https://evil.example/file",
|
||||
"https://aliyuncs.com.evil.example/file",
|
||||
"https://127.0.0.1/file",
|
||||
"https://user:secret@download.dingtalk.com/file",
|
||||
"https://download.dingtalk.com:8443/file",
|
||||
"http://download.dingtalk.com:8443/file",
|
||||
} {
|
||||
if _, _, err := resourceDownloadInfo(
|
||||
map[string]any{"resourceUrl": resourceURL},
|
||||
@@ -93,6 +90,24 @@ func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
|
||||
t.Fatalf("untrusted URL %q unexpectedly accepted", resourceURL)
|
||||
}
|
||||
}
|
||||
// The static host allowlist and the IP-literal refusal are both retired:
|
||||
// dedicated-deployment download hosts and IP literals pass the same
|
||||
// host-agnostic HTTPS policy as DingTalk/OSS hosts, mirroring the GUI
|
||||
// client which applies no client-side SSRF interception.
|
||||
// Non-default HTTPS ports are accepted too: dedicated storage domains
|
||||
// legitimately serve on them.
|
||||
for _, resourceURL := range []string{
|
||||
"https://download.dingtalk.com/file",
|
||||
"https://ddoss.tenant.example.com/file",
|
||||
"https://ddoss.tenant.example.com:8443/file",
|
||||
"https://127.0.0.1/file",
|
||||
} {
|
||||
if _, _, err := resourceDownloadInfo(
|
||||
map[string]any{"resourceUrl": resourceURL},
|
||||
); err != nil {
|
||||
t.Fatalf("HTTPS domain %q unexpectedly rejected: %v", resourceURL, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveResourceDownloadPath(t *testing.T) {
|
||||
|
||||
@@ -156,6 +156,16 @@ func docEnvelope(operation string, data any, steps ...map[string]any) map[string
|
||||
}
|
||||
}
|
||||
|
||||
// withDocWarnings fills the envelope's warnings slot, which docEnvelope has
|
||||
// always emitted empty. Written as a wrapper rather than another docEnvelope
|
||||
// parameter so its ~20 existing call sites stay untouched.
|
||||
func withDocWarnings(envelope map[string]any, warnings []string) map[string]any {
|
||||
if len(warnings) > 0 {
|
||||
envelope["warnings"] = warnings
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
type docFailureState string
|
||||
|
||||
const (
|
||||
|
||||
@@ -97,12 +97,26 @@ var Create = shortcut.Shortcut{
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
contentChunks := []string{content}
|
||||
// expected is what the server should hold once every chunk is appended.
|
||||
// It differs from content whenever a boundary needed repair (a repeated
|
||||
// table header, a reopened fence), so verification must compare against
|
||||
// this rather than the raw input.
|
||||
expected := content
|
||||
var chunkPlan helpers.MarkdownChunkPlan
|
||||
if format == "markdown" && content != "" {
|
||||
contentChunks = splitDocMarkdown(content, 10000)
|
||||
chunkPlan = helpers.SplitMarkdownForAppend(content, helpers.DefaultMarkdownChunkRunes)
|
||||
contentChunks = chunkPlan.Chunks
|
||||
expected = chunkPlan.ExpectedDocument()
|
||||
params["markdown"] = contentChunks[0]
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(docEnvelope("doc.create", map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)}))
|
||||
preview := map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)}
|
||||
if len(contentChunks) > 1 {
|
||||
// Surfacing the plan in --dry-run lets a caller see "your table
|
||||
// will become three tables" before anything is written.
|
||||
preview["chunkPlan"] = chunkPlan.Summary()
|
||||
}
|
||||
return rt.Output(withDocWarnings(docEnvelope("doc.create", preview), chunkPlan.Warnings()))
|
||||
}
|
||||
created, err := rt.CallMCPWriteData(productDoc, "create_document", params)
|
||||
if err != nil {
|
||||
@@ -141,7 +155,8 @@ var Create = shortcut.Shortcut{
|
||||
"doc.create", "doc_create_chunk_commit_unknown", stepName,
|
||||
fmt.Sprintf("文档已创建,但第 %d/%d 个内容分片失败或提交状态未知;请先回读,不要重试整个创建", index+2, len(contentChunks)),
|
||||
err,
|
||||
map[string]any{"nodeId": nodeID, "chunksWritten": index + 1, "chunksTotal": len(contentChunks), "verified": false},
|
||||
map[string]any{"nodeId": nodeID, "chunksWritten": index + 1, "chunksTotal": len(contentChunks),
|
||||
"verified": false, "degradations": chunkPlan.Degradations},
|
||||
append(steps, map[string]any{"name": stepName, "status": "unknown"}),
|
||||
map[string]any{"available": false, "reason": "inspect the current document and resume only confirmed missing content"},
|
||||
)
|
||||
@@ -156,16 +171,20 @@ var Create = shortcut.Shortcut{
|
||||
verifyParams["format"] = format
|
||||
}
|
||||
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
|
||||
return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format)
|
||||
return content == "" || verifyUpdatedDocumentContent(data, expected, "overwrite", format)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
if content != "" && !verifyUpdatedDocumentContent(verification, content, "overwrite", format) {
|
||||
if content != "" && !verifyUpdatedDocumentContent(verification, expected, "overwrite", format) {
|
||||
return docVerificationError("doc.create", "verify", nodeID, fmt.Errorf("回读结果与完整初始内容不一致"), append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
|
||||
return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created, "verified": true, "verification": verification}, steps...))
|
||||
data := map[string]any{"nodeId": nodeID, "result": created, "verified": true, "verification": verification}
|
||||
if len(contentChunks) > 1 {
|
||||
data["chunkPlan"] = chunkPlan.Summary()
|
||||
}
|
||||
return rt.Output(withDocWarnings(docEnvelope("doc.create", data, steps...), chunkPlan.Warnings()))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -443,10 +462,19 @@ var CheckpointUpdate = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// --content accepts @file and stdin, so oversized content is reachable
|
||||
// here exactly as it is on +update. Chunk it the same way rather than
|
||||
// sending one oversized call.
|
||||
chunkPlan := helpers.SplitMarkdownForAppend(content, helpers.DefaultMarkdownChunkRunes)
|
||||
chunks := chunkPlan.Chunks
|
||||
expected := chunkPlan.ExpectedDocument()
|
||||
plan := map[string]any{"nodeId": rt.Str("node"), "mode": rt.Str("mode"), "contentBytes": len(content), "steps": []string{"save_doc_version", "update_document", "get_document_content"}}
|
||||
if len(chunks) > 1 {
|
||||
plan["chunkPlan"] = chunkPlan.Summary()
|
||||
}
|
||||
if rt.DryRun() {
|
||||
plan["executed"] = false
|
||||
return rt.Output(docEnvelope("doc.checkpoint_update", plan))
|
||||
return rt.Output(withDocWarnings(docEnvelope("doc.checkpoint_update", plan), chunkPlan.Warnings()))
|
||||
}
|
||||
steps := []map[string]any{}
|
||||
checkpoint, err := rt.CallMCPWriteData(productDoc, "save_doc_version", map[string]any{"nodeId": rt.Str("node")})
|
||||
@@ -454,24 +482,41 @@ var CheckpointUpdate = shortcut.Shortcut{
|
||||
return err
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "checkpoint", "status": "success"})
|
||||
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": rt.Str("node"), "markdown": content, "mode": rt.Str("mode")}); err != nil {
|
||||
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "update", "doc_checkpoint_update_failed", err,
|
||||
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
|
||||
for index, chunk := range chunks {
|
||||
// Only the first chunk honours --mode; the rest must append, or an
|
||||
// overwrite would discard everything written before it.
|
||||
mode := "append"
|
||||
if index == 0 {
|
||||
mode = rt.Str("mode")
|
||||
}
|
||||
stepName := "update"
|
||||
if len(chunks) > 1 {
|
||||
stepName = fmt.Sprintf("update_chunk_%d", index+1)
|
||||
}
|
||||
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": rt.Str("node"), "markdown": chunk, "mode": mode}); err != nil {
|
||||
return checkpointPartialWriteError(rt.Str("node"), checkpoint, stepName, "doc_checkpoint_update_failed", err,
|
||||
append(steps, map[string]any{"name": stepName, "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": stepName, "status": "success"})
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "update", "status": "success"})
|
||||
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool {
|
||||
return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "markdown")
|
||||
return verifyUpdatedDocumentContent(data, expected, rt.Str("mode"), "markdown")
|
||||
})
|
||||
if err != nil {
|
||||
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
|
||||
append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(verification, content, rt.Str("mode"), "markdown") {
|
||||
if !verifyUpdatedDocumentContent(verification, expected, rt.Str("mode"), "markdown") {
|
||||
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", fmt.Errorf("回读结果未匹配预期变更"),
|
||||
append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
|
||||
return rt.Output(docEnvelope("doc.checkpoint_update", map[string]any{"nodeId": rt.Str("node"), "verified": true, "verification": verification}, steps...))
|
||||
data := map[string]any{"nodeId": rt.Str("node"), "verified": true, "verification": verification}
|
||||
if len(chunks) > 1 {
|
||||
data["chunksWritten"] = len(chunks)
|
||||
data["chunkPlan"] = chunkPlan.Summary()
|
||||
}
|
||||
return rt.Output(withDocWarnings(docEnvelope("doc.checkpoint_update", data, steps...), chunkPlan.Warnings()))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -743,8 +788,15 @@ func executeVerifiedDocMutation(
|
||||
|
||||
func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams map[string]any, nodeID, content, mode, format string) error {
|
||||
chunks := []string{content}
|
||||
// See the doc.create path: once a boundary needs repair the server legitimately
|
||||
// ends up holding something other than the raw input, so verification has to
|
||||
// compare against what we actually sent.
|
||||
expected := content
|
||||
var chunkPlan helpers.MarkdownChunkPlan
|
||||
if format == "markdown" {
|
||||
chunks = splitDocMarkdown(content, 10000)
|
||||
chunkPlan = helpers.SplitMarkdownForAppend(content, helpers.DefaultMarkdownChunkRunes)
|
||||
chunks = chunkPlan.Chunks
|
||||
expected = chunkPlan.ExpectedDocument()
|
||||
firstParams["markdown"] = chunks[0]
|
||||
}
|
||||
steps := make([]map[string]any, 0, len(chunks)+1)
|
||||
@@ -766,7 +818,8 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
|
||||
"doc.update", "doc_update_chunk_commit_unknown", stepName,
|
||||
fmt.Sprintf("文档已写入 %d/%d 个分片,但当前分片失败或提交状态未知;请先回读,不要重放已完成分片", index, len(chunks)),
|
||||
err,
|
||||
map[string]any{"nodeId": nodeID, "mode": mode, "chunksWritten": index, "chunksTotal": len(chunks), "lastResult": result, "verified": false},
|
||||
map[string]any{"nodeId": nodeID, "mode": mode, "chunksWritten": index, "chunksTotal": len(chunks),
|
||||
"lastResult": result, "verified": false, "degradations": chunkPlan.Degradations},
|
||||
append(steps, map[string]any{"name": stepName, "status": "unknown"}),
|
||||
map[string]any{"available": false, "reason": "inspect current content before resuming from a confirmed missing boundary"},
|
||||
)
|
||||
@@ -774,18 +827,22 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
|
||||
steps = append(steps, map[string]any{"name": stepName, "status": "success"})
|
||||
}
|
||||
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool {
|
||||
return verifyUpdatedDocumentContent(data, content, mode, format)
|
||||
return verifyUpdatedDocumentContent(data, expected, mode, format)
|
||||
})
|
||||
if err != nil {
|
||||
return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(verification, content, mode, format) {
|
||||
if !verifyUpdatedDocumentContent(verification, expected, mode, format) {
|
||||
return docVerificationError("doc.update", "verify", nodeID, fmt.Errorf("回读结果未包含预期内容"), append(steps, map[string]any{"name": "verify", "status": "failed"}))
|
||||
}
|
||||
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
|
||||
return rt.Output(docEnvelope("doc.update", map[string]any{
|
||||
data := map[string]any{
|
||||
"nodeId": nodeID, "mode": mode, "chunksWritten": len(chunks), "verified": true, "verification": verification,
|
||||
}, steps...))
|
||||
}
|
||||
if len(chunks) > 1 {
|
||||
data["chunkPlan"] = chunkPlan.Summary()
|
||||
}
|
||||
return rt.Output(withDocWarnings(docEnvelope("doc.update", data, steps...), chunkPlan.Warnings()))
|
||||
}
|
||||
|
||||
func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) {
|
||||
@@ -962,33 +1019,6 @@ func nestedNonNegativeInt(value any, keys ...string) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func splitDocMarkdown(content string, maxRunes int) []string {
|
||||
if maxRunes <= 0 {
|
||||
return []string{content}
|
||||
}
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxRunes {
|
||||
return []string{content}
|
||||
}
|
||||
chunks := make([]string, 0, (len(runes)+maxRunes-1)/maxRunes)
|
||||
for start := 0; start < len(runes); {
|
||||
end := start + maxRunes
|
||||
if end >= len(runes) {
|
||||
end = len(runes)
|
||||
} else {
|
||||
for split := end; split > start; split-- {
|
||||
if runes[split-1] == '\n' {
|
||||
end = split
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, string(runes[start:end]))
|
||||
start = end
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func containsText(value any, needle string) bool {
|
||||
if needle == "" {
|
||||
return true
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
@@ -156,16 +157,28 @@ func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
|
||||
if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" {
|
||||
t.Fatalf("nested content candidates = %#v", got)
|
||||
}
|
||||
if got := splitDocMarkdown("a\nb", 0); len(got) != 1 {
|
||||
t.Fatalf("disabled split = %#v", got)
|
||||
// splitDocMarkdown was replaced by the shared splitter. Its two load-bearing
|
||||
// contracts are kept: a non-positive limit disables splitting entirely...
|
||||
if got := helpers.SplitMarkdownForAppend("a\nb", 0); len(got.Chunks) != 1 {
|
||||
t.Fatalf("disabled split = %#v", got.Chunks)
|
||||
}
|
||||
if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" {
|
||||
t.Fatalf("newline split = %#v", got)
|
||||
// ...and a long single paragraph still splits at the line boundary. What
|
||||
// changed deliberately: the boundary newline no longer trails the preceding
|
||||
// chunk (it used to be "ab\n"), because a chunk is now a self-contained block
|
||||
// sequence rather than a raw byte range, and the paragraph break is reported.
|
||||
plan := helpers.SplitMarkdownForAppend("ab\ncd", 4)
|
||||
if len(plan.Chunks) != 2 || plan.Chunks[0] != "ab" || plan.Chunks[1] != "cd" {
|
||||
t.Fatalf("newline split = %#v", plan.Chunks)
|
||||
}
|
||||
if len(plan.Degradations) != 1 || plan.Degradations[0].Kind != "paragraph_split" {
|
||||
t.Fatalf("newline split degradations = %#v", plan.Degradations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) {
|
||||
longContent := strings.Repeat("x", 10001)
|
||||
// Must exceed the production chunk limit to reach the chunk-append branch;
|
||||
// tie it to the constant so a limit bump cannot silently drop that coverage.
|
||||
longContent := strings.Repeat("x", helpers.DefaultMarkdownChunkRunes+1)
|
||||
if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil {
|
||||
t.Fatal("partial chunk create succeeded")
|
||||
}
|
||||
|
||||
@@ -756,14 +756,22 @@ func TestCrossPlatformCoverageDocWriteErrorStateMachine(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
|
||||
long := strings.Repeat("段落😀", 4000)
|
||||
chunks := splitDocMarkdown(long, 10000)
|
||||
// Derive the fixture from the production limit: a hardcoded size silently
|
||||
// becomes a single-chunk write when the limit grows, which stops covering
|
||||
// the chunked-append branch without failing anything.
|
||||
long := strings.Repeat("段落😀", helpers.DefaultMarkdownChunkRunes/3+100)
|
||||
plan := helpers.SplitMarkdownForAppend(long, helpers.DefaultMarkdownChunkRunes)
|
||||
chunks := plan.Chunks
|
||||
if len(chunks) < 2 || strings.Join(chunks, "") != long {
|
||||
t.Fatalf("split chunks=%d roundtrip=%v", len(chunks), strings.Join(chunks, "") == long)
|
||||
}
|
||||
|
||||
// The fake readback must return what the server would actually hold after
|
||||
// appending every chunk, not an echo of the input. Echoing the input hid the
|
||||
// fact that verification compared against content the server never receives
|
||||
// once a boundary needs repair.
|
||||
update := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": long}},
|
||||
"get_document_content": {{"markdown": plan.ExpectedDocument()}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, update, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -780,7 +788,7 @@ func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
|
||||
}
|
||||
|
||||
create := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": long}},
|
||||
"get_document_content": {{"markdown": plan.ExpectedDocument()}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Create, create, "--name", "long", "--content", long); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -788,6 +796,112 @@ func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
|
||||
if len(create.history) != len(chunks)+1 || create.history[0].tool != "create_document" || create.history[1].tool != "update_document" || create.history[len(create.history)-1].tool != "get_document_content" {
|
||||
t.Fatalf("long create calls = %#v", create.history)
|
||||
}
|
||||
|
||||
// Guard against the expectation change weakening verification into a
|
||||
// tautology: a truncated readback must still fail.
|
||||
truncated := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": chunks[0]}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, truncated, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err == nil {
|
||||
t.Fatal("a readback missing the later chunks must fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocChunkedTableRepeatsHeaderAndReportsIt(t *testing.T) {
|
||||
// A table longer than the limit cannot be written as one table: mode=append
|
||||
// always inserts a new structure, so each chunk must carry the header itself.
|
||||
header := "| 姓名 | 部门 | 工号 |\n|---|---|---|\n"
|
||||
content := header + strings.Repeat("| 张三 | 技术部 | 10086 |\n", 4000)
|
||||
plan := helpers.SplitMarkdownForAppend(content, helpers.DefaultMarkdownChunkRunes)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("fixture must exceed the limit, got %d chunk(s)", len(plan.Chunks))
|
||||
}
|
||||
if len(plan.Degradations) == 0 || plan.Degradations[0].Kind != "table_split" {
|
||||
t.Fatalf("degradations = %#v", plan.Degradations)
|
||||
}
|
||||
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": plan.ExpectedDocument()}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "overwrite", "--content", content, "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Every appended chunk must open with the re-emitted header and delimiter,
|
||||
// otherwise the server sees orphaned rows.
|
||||
appended := 0
|
||||
for _, call := range caller.history {
|
||||
if call.tool != "update_document" || call.params["mode"] != "append" {
|
||||
continue
|
||||
}
|
||||
appended++
|
||||
markdown, _ := call.params["markdown"].(string)
|
||||
if !strings.HasPrefix(markdown, header) {
|
||||
t.Errorf("appended chunk lost the header: %.60q", markdown)
|
||||
}
|
||||
}
|
||||
if appended == 0 {
|
||||
t.Fatalf("no append call was made: %#v", caller.history)
|
||||
}
|
||||
|
||||
// --dry-run must report the plan without writing anything, so a caller can
|
||||
// see the table will be split before committing to it.
|
||||
dry := &docCoverageCaller{}
|
||||
if err := runDocCoverage(t, Create, dry, "--name", "n", "--content", content, "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(dry.history) != 0 {
|
||||
t.Fatalf("dry run wrote something: %#v", dry.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCheckpointUpdateChunksOversizedContent(t *testing.T) {
|
||||
// +checkpoint-update takes the same @file / stdin content as +update, so
|
||||
// oversized input is reachable. Before this it sent one oversized call while
|
||||
// +update chunked — same operation, different behaviour.
|
||||
content := strings.Repeat("段落文字\n\n", helpers.DefaultMarkdownChunkRunes/6+200)
|
||||
plan := helpers.SplitMarkdownForAppend(content, helpers.DefaultMarkdownChunkRunes)
|
||||
if len(plan.Chunks) < 2 {
|
||||
t.Fatalf("fixture must exceed the limit, got %d chunk(s)", len(plan.Chunks))
|
||||
}
|
||||
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": plan.ExpectedDocument()}},
|
||||
}}
|
||||
if err := runDocCoverage(t, CheckpointUpdate, caller, "--node", "n", "--mode", "overwrite", "--content", content, "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var modes []string
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "update_document" {
|
||||
mode, _ := call.params["mode"].(string)
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
}
|
||||
if len(modes) != len(plan.Chunks) {
|
||||
t.Fatalf("update calls = %v, want %d", modes, len(plan.Chunks))
|
||||
}
|
||||
// Only the first chunk may overwrite; a later overwrite would discard
|
||||
// everything already written.
|
||||
if modes[0] != "overwrite" {
|
||||
t.Errorf("first chunk mode = %q", modes[0])
|
||||
}
|
||||
for i, mode := range modes[1:] {
|
||||
if mode != "append" {
|
||||
t.Errorf("chunk %d mode = %q, want append", i+2, mode)
|
||||
}
|
||||
}
|
||||
|
||||
// A failure on a later chunk must report the checkpoint so the caller can
|
||||
// roll back rather than blindly retry.
|
||||
partial := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, CheckpointUpdate, partial, "--node", "n", "--mode", "overwrite", "--content", content, "--yes")
|
||||
if err == nil {
|
||||
t.Fatal("a failed later chunk must surface an error")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_checkpoint_update_failed" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUpdateVerificationNormalizesMarkdownRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -90,6 +93,22 @@ var DocAppend = shortcut.Shortcut{
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewValidation("--content 不能为空,请提供要追加的文本")
|
||||
}
|
||||
// --text is a plain argv string with no @file or stdin input, so this
|
||||
// command is for appending a paragraph, not a document. Rather than build
|
||||
// a second chunk loop on top of CallMCP (which returns only an error and
|
||||
// so cannot report partial progress), point oversized input at the
|
||||
// command that already chunks and verifies.
|
||||
if runes := utf8.RuneCountInString(text); runes > helpers.DefaultMarkdownChunkRunes {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("--text 长度 %d 字符超过单次写入上限 %d;本命令只用于追加一小段文本,不做自动分片", runes, helpers.DefaultMarkdownChunkRunes),
|
||||
apperrors.WithReason("doc_append_content_too_long"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions(
|
||||
"改用 dws doc +update --command append --content @文件 —— 它会自动分片、重发表头并回读校验",
|
||||
"或自行把文本拆成多段,分多次 +doc-append",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// update_document append: params copied verbatim from the helper's
|
||||
// `doc update --mode append` call site — nodeId + markdown + mode.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDocAppendRejectsOversizedText(t *testing.T) {
|
||||
// +doc-append takes --text from argv only (no @file, no stdin) and writes via
|
||||
// CallMCP, which returns just an error and so cannot report partial progress
|
||||
// across chunks. Rather than grow a second chunk loop here, oversized input
|
||||
// is pointed at the command that already chunks, repeats table headers and
|
||||
// verifies.
|
||||
fake := &stubMailboxCaller{}
|
||||
long := strings.Repeat("字", helpers.DefaultMarkdownChunkRunes+1)
|
||||
err := runShortcutErr(t, fake, "doc", "+doc-append", "--doc", "node-1", "--text", long, "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_append_content_too_long" {
|
||||
t.Fatalf("expected a fail-closed validation error, got %#v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(typed.Actions, " "), "+update") {
|
||||
t.Errorf("error must point at the chunking command: %#v", typed.Actions)
|
||||
}
|
||||
|
||||
// Exactly at the limit is still allowed, so the guard is a ceiling rather
|
||||
// than an off-by-one refusal.
|
||||
atLimit := strings.Repeat("字", helpers.DefaultMarkdownChunkRunes)
|
||||
if err := runShortcutErr(t, fake, "doc", "+doc-append", "--doc", "node-1", "--text", atLimit, "--yes"); err != nil {
|
||||
t.Fatalf("content at the limit must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -66,36 +66,6 @@ func openSupplementServers() []ServerInfo {
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff",
|
||||
Prefixes: []string{"recruit", "job"},
|
||||
},
|
||||
{
|
||||
ID: "edu-contact",
|
||||
Name: "家校通讯录",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2",
|
||||
Prefixes: []string{"edu-contact", "edu"},
|
||||
},
|
||||
{
|
||||
ID: "edu-group",
|
||||
Name: "家校群",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a",
|
||||
Prefixes: []string{"edu-group"},
|
||||
},
|
||||
{
|
||||
ID: "edu-app",
|
||||
Name: "家校应用",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e",
|
||||
Prefixes: []string{"edu-app"},
|
||||
},
|
||||
{
|
||||
ID: "edu-familygroup",
|
||||
Name: "家庭群",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944",
|
||||
Prefixes: []string{"edu-familygroup"},
|
||||
},
|
||||
{
|
||||
ID: "college-contact",
|
||||
Name: "高校通讯录",
|
||||
Endpoint: "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474",
|
||||
Prefixes: []string{"college-contact"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-32
@@ -138,41 +138,22 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints(t *testing.T) {
|
||||
servers := openSupplementServers()
|
||||
byID := make(map[string]ServerInfo, len(servers))
|
||||
for _, s := range servers {
|
||||
byID[s.ID] = s
|
||||
func TestCrossPlatformCoverageOpenSupplementServersExcludesRetiredEduEndpoints(t *testing.T) {
|
||||
retiredProducts := map[string]bool{
|
||||
"edu-contact": true,
|
||||
"edu-group": true,
|
||||
"edu-app": true,
|
||||
"edu-familygroup": true,
|
||||
"college-contact": true,
|
||||
}
|
||||
|
||||
edu := []struct {
|
||||
id string
|
||||
endpoint string
|
||||
prefixes []string
|
||||
}{
|
||||
{"edu-contact", "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2", []string{"edu-contact", "edu"}},
|
||||
{"edu-group", "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a", []string{"edu-group"}},
|
||||
{"edu-app", "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e", []string{"edu-app"}},
|
||||
{"edu-familygroup", "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944", []string{"edu-familygroup"}},
|
||||
{"college-contact", "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474", []string{"college-contact"}},
|
||||
}
|
||||
|
||||
for _, want := range edu {
|
||||
got, ok := byID[want.id]
|
||||
if !ok {
|
||||
t.Errorf("openSupplementServers() missing edu endpoint %q", want.id)
|
||||
continue
|
||||
for _, server := range openSupplementServers() {
|
||||
if retiredProducts[server.ID] {
|
||||
t.Errorf("openSupplementServers() still exposes retired endpoint %q", server.ID)
|
||||
}
|
||||
if got.Endpoint != want.endpoint {
|
||||
t.Errorf("%s endpoint = %q, want %q", want.id, got.Endpoint, want.endpoint)
|
||||
}
|
||||
if len(got.Prefixes) != len(want.prefixes) {
|
||||
t.Errorf("%s prefixes length = %d, want %d", want.id, len(got.Prefixes), len(want.prefixes))
|
||||
continue
|
||||
}
|
||||
for i, p := range want.prefixes {
|
||||
if got.Prefixes[i] != p {
|
||||
t.Errorf("%s prefixes[%d] = %q, want %q", want.id, i, got.Prefixes[i], p)
|
||||
for _, prefix := range server.Prefixes {
|
||||
if retiredProducts[prefix] {
|
||||
t.Errorf("openSupplementServers() endpoint %q still routes retired prefix %q", server.ID, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,46 @@
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter."
|
||||
},
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"reason": "无接收人的日志提交服务端仍返回成功但日志对任何人都不可见;仅在 dws CLI 侧将 --to-user-ids 提升为必填,openAPI create_report 保持可选(bug 85724185)"
|
||||
},
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report create",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"reason": "废弃别名 dws report create(含 dws log create 拼写)与 dws report entry submit 共用 addReportCreateFlags,required 提升必须两侧一致;openAPI create_report 保持可选(bug 85724185)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -112,26 +112,6 @@ coverage:
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/recruit.md
|
||||
- mono: edu-app
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-app.md
|
||||
- mono: edu-contact
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-contact.md
|
||||
- mono: edu-group
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-group.md
|
||||
- mono: college-contact
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/college-contact.md
|
||||
- mono: edu-familygroup
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
- references/edu-familygroup.md
|
||||
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
|
||||
@@ -99,11 +99,6 @@ cli_version: ">=1.0.15"
|
||||
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
|
||||
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
|
||||
| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) |
|
||||
| `edu-app` | 家校应用(教育版):家校任务、班级消息摘要等教育场景应用能力 | [edu-app.md](./references/products/edu-app.md) |
|
||||
| `edu-contact` | 家校通讯录(教育版):学校组织架构、班级列表等教育场景通讯录能力 | [edu-contact.md](./references/products/edu-contact.md) |
|
||||
| `edu-group` | 家校群(师生群):班级群/师生群的查询、创建、解散 | [edu-group.md](./references/products/edu-group.md) |
|
||||
| `edu-familygroup` | 家庭群:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制 | [edu-familygroup.md](./references/products/edu-familygroup.md) |
|
||||
| `college-contact` | 高校通讯录:高校组织架构/院系部门管理(查询/创建/更新/删除)/师生员工管理(查询/添加/移除/变更类型与部门/激活短信)/通讯录搜索/概览统计/升级 | [college-contact.md](./references/products/college-contact.md) |
|
||||
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
@@ -173,8 +168,6 @@ cli_version: ">=1.0.15"
|
||||
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
|
||||
| `todo` | `task delete` | 删除待办 |
|
||||
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
|
||||
| `college-contact` | `dept delete` | 删除高校部门,不可恢复 |
|
||||
| `college-contact` | `employee remove` | 移除高校员工,不可恢复 |
|
||||
|
||||
### 确认流程
|
||||
```
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清<br>1. 用户说「我发过 / 我创建」→ `report outbox list --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`<br>2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证<br>3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表<br>4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造<br>5. 用户要看正文时再执行 `report entry get --report-id <reportId> --format json`;用户要统计 / 已读情况时执行 `report entry stats --report-id <reportId> --format json`<br>**不要把 inbox list/outbox list 当正文接口**;查询正文必须补 `entry get`<br>**不要再生成** `report list` / `report sent` / `report detail` / `report stats`(deprecated alias,仍能跑但会打 stderr 警告) |
|
||||
| generate-daily-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=今日)<br>2. 交叉汇总并把日报内容写入临时文件 `<tmp>.md`(UTF-8,真实换行)<br>3. **创建文档**:`doc create --name "<日报名>" --content-file <tmp>.md`(> 200KB 按 [write-doc 兜底](./04-document.md) 走 create 空 → 循环 update) |
|
||||
| generate-weekly-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=本周)<br>2. 交叉对比并把周报内容写入临时文件 `<tmp>.md`<br>3. **创建文档**:`doc create --name "<周报名>" --content-file <tmp>.md`(兜底同上) |
|
||||
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --format json` → CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
|
||||
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --to-user-ids <userId1>,<userId2> --format json` → `--to-user-ids` 必填:无接收人的提交服务端仍返回成功但日志对任何人都不可见;CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
|
||||
| generate-monthly-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=当月)<br>2. `report outbox list --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
|
||||
| generate-topic-report | 1. 提取主题关键词;推断时间范围("最近"默认近 30 天)<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行<br>3. 按时间线排列,交叉归纳核心结论/决策/行动项/未解决问题/演进脉络,并把内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<报告名>" --content-file <tmp>.md`(兜底同上) |
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# 高校通讯录 (college-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### dept (部门管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 |
|
||||
| `dept get-detail` | 查询部门详情 | `--dept-id` |
|
||||
| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` |
|
||||
| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` |
|
||||
| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` |
|
||||
| `dept update` | 更新部门 | `--dept-id`, `--dept-type` |
|
||||
| `dept delete` | 删除部门 ⚠️ | `--dept-id` |
|
||||
| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` |
|
||||
| `dept overview` | 查询高校概览统计 | 无 |
|
||||
|
||||
### employee (员工管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `employee get-detail` | 查询员工详情 | `--staff-id` |
|
||||
| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` |
|
||||
| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) |
|
||||
| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` |
|
||||
| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` |
|
||||
| `employee send-active-sms` | 发送激活短信 | `--dept-id` |
|
||||
| `employee list-employees` | 查询部门员工列表 | `--dept-id` |
|
||||
| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` |
|
||||
| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` |
|
||||
| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 |
|
||||
| `employee start-upgrade` | 启动高校通讯录升级 | 无 |
|
||||
|
||||
### alumni (校友管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` |
|
||||
| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` |
|
||||
| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` |
|
||||
| `alumni query` | 查询单个校友详情 | `--staff-id` |
|
||||
| `alumni search` | 搜索校友 | `--keyword` |
|
||||
| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` |
|
||||
| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` |
|
||||
| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) |
|
||||
| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` |
|
||||
| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `alumni create-group` | 创建校友群 | `--alumni-dept-id` |
|
||||
| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 |
|
||||
| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` |
|
||||
| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) |
|
||||
|
||||
### graduate (毕业年级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 |
|
||||
| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) |
|
||||
| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` |
|
||||
| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) |
|
||||
| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) |
|
||||
| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 |
|
||||
| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` |
|
||||
| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) |
|
||||
| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) |
|
||||
| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` |
|
||||
| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) |
|
||||
|
||||
### group (规则管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) |
|
||||
| `group get-group-rule-schedule` | 查询规则调度 | 无参数 |
|
||||
| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) |
|
||||
| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) |
|
||||
| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` |
|
||||
| `group enable-group-rule` | 启用规则 | `--rule-id` |
|
||||
| `group disable-group-rule` | 停用规则 | `--rule-id` |
|
||||
| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) |
|
||||
| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 |
|
||||
|
||||
## 常用参数说明
|
||||
|
||||
- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工)
|
||||
- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业)
|
||||
- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表
|
||||
- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序
|
||||
- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false)
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"高校架构/组织架构/学院/系/部门" → dept 子命令
|
||||
用户说"搜人/找某某老师/找某某同学" → `dept search`
|
||||
用户说"师生/教职工/学生/员工/辅导员" → employee 子命令
|
||||
用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms`
|
||||
用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade`
|
||||
用户说"校友/校友会/校友部门/添加校友" → alumni 子命令
|
||||
用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令
|
||||
用户说"群规则/建群规则/自动建群" → group 子命令
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查标准架构 → `dept get-standard-structure`(提取 deptId)
|
||||
2. 查看部门详情 → `dept get-detail --dept-id <deptId>`
|
||||
3. 查看部门员工 → `employee list-employees --dept-id <deptId>`
|
||||
4. 查看员工详情 → `employee get-detail --staff-id <staffId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id |
|
||||
| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids |
|
||||
| `dept create` | `deptId` | update/delete 的 --dept-id |
|
||||
| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id |
|
||||
| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id |
|
||||
| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id |
|
||||
| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id |
|
||||
|
||||
## 危险操作
|
||||
|
||||
- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。
|
||||
@@ -398,7 +398,7 @@ Usage:
|
||||
|
||||
### 内容写入管道(create / update 共用)
|
||||
|
||||
> **关键原则**:CLI 内置自动分片。超长内容(>10000 字符)自动按 markdown 结构切分后逐片写入,对调用方透明。写入完成后由调用方自行决定是否回读确认。
|
||||
> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动按 markdown 结构切分后逐片写入,每一片都是完整自包含的顶层 block 序列(表格切分会重发表头行)。任何改变渲染结构的切分点都会在 `degradations` 里上报,不会静默降级。写入完成后由调用方自行决定是否回读确认。
|
||||
|
||||
#### 输入方式选择
|
||||
|
||||
@@ -411,14 +411,16 @@ Usage:
|
||||
|
||||
#### 自动分片行为
|
||||
|
||||
当内容超过 10000 字符时,CLI 自动执行:
|
||||
1. **create**: 先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append
|
||||
当内容超过 30000 字符(rune 数)时,CLI 自动执行:
|
||||
1. **create**: 先创建空文档拿 `nodeId`,再切分后逐片 append
|
||||
2. **update (overwrite)**: 第一片用 overwrite,后续片用 append
|
||||
3. **update (append)**: 所有片段用 append
|
||||
|
||||
分片策略按优先级:H1 标题 → H2 标题 → H3 标题 → 空行(段落边界)→ 硬切(保留表格/代码块完整性)
|
||||
服务端 `mode=append` 每次插入的都是全新结构,无法延续上一片,所以每一片都必须是完整自包含的顶层 block 序列。切分点严格按对文档的影响分档选择:完全安全(空行、能中断段落的块起始)→ 需注入修复(表格行边界重发表头行与分隔行、代码块行边界补围栏)→ 结构变化(长段落内换行、列表项之间)→ 硬切(仅当单行超限)。同档位内取最靠后的切分点。
|
||||
|
||||
如果某片写入超时,自动将分片大小减半重试(最小 5000 字符,低于此值报错)。
|
||||
任何改变了渲染结构的切分点都会在 `degradations` 里如实上报,不会静默降级。`--index` 与自动分片互斥(第 2 片的插入位置不可知),超限时带 `--index` 会直接报错。
|
||||
|
||||
详细分档规则见 [`./doc/doc-update.md` §自动分片行为](./doc/doc-update.md#自动分片行为)。
|
||||
|
||||
#### 输出格式
|
||||
|
||||
@@ -442,8 +444,8 @@ CLI **不会**自动执行回读验证。**Agent 必须在文档写入完成后
|
||||
3. 如发现内容缺失或异常,使用 `dws doc update --mode append` 补写缺失部分
|
||||
|
||||
> **何时回读**:每次 create / update 操作完成后**必须**回读。
|
||||
> - 单次写入(≤10000 字符):写完立即回读一次
|
||||
> - 分片写入(>10000 字符):所有分片写完后回读一次全文,校验关键标题与段落完整性
|
||||
> - 单次写入(≤30000 字符):写完立即回读一次
|
||||
> - 分片写入(>30000 字符):所有分片写完后回读一次全文;先看 `degradations`,为空即表示分片未改变渲染结构
|
||||
> - 破坏性 overwrite(`--mode overwrite --yes`):**必须**回读,确认 overwrite 未被后端静默降级为 append(详见 [best_practices/04-document.md "doc update 回读校验规范"](../best_practices/04-document.md#doc-update-回读校验规范))
|
||||
> - 连续多次编辑同一文档:可在全部编辑完成后统一回读一次
|
||||
>
|
||||
@@ -452,21 +454,23 @@ CLI **不会**自动执行回读验证。**Agent 必须在文档写入完成后
|
||||
#### 进度输出示例
|
||||
|
||||
```
|
||||
[INFO] 内容较长 (25000 字符),自动分片写入...
|
||||
[INFO] 已创建空文档 (nodeId=abc123),开始分片写入...
|
||||
[INFO] 写入分片 (1/3),10000 字符...
|
||||
[INFO] 写入分片 (2/3),10000 字符...
|
||||
[INFO] 写入分片 (3/3),5000 字符...
|
||||
[INFO] 内容较长 (72000 字符),自动分片写入...
|
||||
[INFO] [WARN] 内容过长已分片:表格被拆成多个表格,后续分片重复表头行与分隔行(第 1420 行)
|
||||
[INFO] 写入分片 (1/3),29989 字符 (overwrite)...
|
||||
[INFO] 写入分片 (2/3),29994 字符, preview=[| 姓名 | 部门 |...]...
|
||||
[INFO] 写入分片 (3/3),12030 字符, preview=[| 姓名 | 部门 |...]...
|
||||
[INFO] 全部 3 个分片写入完成
|
||||
{"success": true, "nodeId": "abc123", "chunksWritten": 3}
|
||||
{"success": true, "nodeId": "abc123", "chunksWritten": 3, "degradations": [{"kind": "table_split", ...}]}
|
||||
```
|
||||
|
||||
#### CONTENT_TRUNCATED 错误
|
||||
#### 分片超时(提交状态未知)
|
||||
|
||||
当分片写入持续超时且减半到最小阈值仍失败时,返回 `CONTENT_TRUNCATED` 错误码。应对策略:
|
||||
1. 检查网络和后端服务状态
|
||||
2. 已写入的部分内容可通过 `dws doc read --node <NODE_ID>` 查看
|
||||
3. 从断点处手动用 `dws doc update --mode append` 继续追加
|
||||
某片写入超时时,服务端是否已提交无法判断。CLI 不会自动重试(重放会重复追加),而是 fail closed 返回 `doc_write_commit_unknown`,`details` 里给出 `chunksWritten` / `chunksTotal` / `failedStage` 与本次的 `degradations`。应对策略:
|
||||
1. 先 `dws doc read --node <NODE_ID>` 回读,确认实际写到哪一片
|
||||
2. 只有确认服务端未提交时才重新执行
|
||||
3. 从断点处用 `dws doc update --mode append` 继续追加;若 `degradations` 含 `table_split`,续写第一片需自带表头行
|
||||
|
||||
> 早期版本在此处会把分片大小减半重试并最终返回 `CONTENT_TRUNCATED`。该逻辑已删除,错误码不再出现。
|
||||
|
||||
### 删除文档/文件到回收站
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Flags:
|
||||
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
|
||||
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
|
||||
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
|
||||
- 长内容(>30000 字符)CLI 自动分片:先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append;调用方无需手动编排。
|
||||
- 长内容(>30000 字符)CLI 自动分片:先创建空文档拿 `nodeId`,再切分后逐片 append;调用方无需手动编排。切分点分档选择、表格切分时重发表头、以及 `degradations` 上报规则见 [`./doc-update.md` §自动分片行为](./doc-update.md#自动分片行为)。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
@@ -53,6 +53,7 @@ Flags:
|
||||
| `nodeId` | [`./doc-update.md`](./doc-update.md) / [`./doc-block.md`](./doc-block.md) / [`./doc-media.md`](./doc-media.md) 的 `--node` |
|
||||
| `docUrl` | 最终交付给用户的链接;缺失时用 [`./doc-info.md`](./doc-info.md) 补查 |
|
||||
| `chunksWritten` | 判断是否触发自动分片;> 1 时重点检查章节顺序 |
|
||||
| `degradations` | 分片是否改变了渲染结构;缺省即表示与输入一致 |
|
||||
|
||||
## 回读验收(必读)
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ Flags:
|
||||
|
||||
## 内容写入管道(create / update 共用)
|
||||
|
||||
> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动按 markdown 结构切分后逐片写入,对调用方透明。写入完成后由调用方自行决定是否回读确认。
|
||||
> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动切分后逐片写入,每一片都是完整自包含的顶层 block 序列(表格切分会重发表头行)。任何改变渲染结构的切分点都在 `degradations` 里上报,不会静默降级。写入完成后由调用方自行决定是否回读确认。
|
||||
|
||||
### 输入方式选择
|
||||
|
||||
@@ -90,13 +90,26 @@ Flags:
|
||||
|
||||
当内容超过 30000 字符时,CLI 自动执行:
|
||||
|
||||
1. **create**: 先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append
|
||||
1. **create**: 先创建空文档拿 `nodeId`,再切分后逐片 append
|
||||
2. **update (overwrite)**: 第一片用 overwrite,后续片用 append
|
||||
3. **update (append)**: 所有片段用 append
|
||||
|
||||
分片策略按优先级:H1 标题 → H2 标题 → H3 标题 → 空行(段落边界)→ 硬切(保留表格/代码块完整性)
|
||||
服务端 `mode=append` 每次插入的都是**全新结构**,无法延续上一片的结构。因此分片的约束是**每一片都必须是完整、自包含的顶层 block 序列** —— 不能有半张表格、未闭合的代码围栏或半个段落。切分点按「对最终文档的影响」分档,严格从高到低选择:
|
||||
|
||||
如果某片写入超时,自动将分片大小减半重试(最小 5000 字符,低于此值报错)。
|
||||
| 档位 | 切分点 | 对文档的影响 |
|
||||
|------|--------|--------------|
|
||||
| 完全安全 | 空行边界;能中断段落的块起始(标题、围栏、引用、`***`、列表首项) | 无,渲染与整篇写入完全一致 |
|
||||
| 需注入修复 | 表格行边界(后续片**重发表头行与分隔行**)、代码块行边界(本片补闭合围栏、下一片补开启围栏) | 一张表变成多张表 / 一个代码块变成多个 |
|
||||
| 结构变化 | 长段落内部换行、列表项之间、引用块行之间 | 一段变多段 / 一个列表变多个 |
|
||||
| 硬切 | 任意字符边界(仅当单行长度就超过上限) | 行被切断,会明确上报 |
|
||||
|
||||
同档位内取窗口里**最靠后**的切分点 —— 所有分片最终 append 进同一篇文档,所以完全安全档内部切在哪里对结果没有影响,切早了只是白多几次网络往返。
|
||||
|
||||
任何改变了渲染结构的切分点都会在 `degradations` 里如实上报,**不会静默降级**。
|
||||
|
||||
**已移除的行为**:早期版本会在分片超时后把分片大小减半重试(最小 5000 字符)。该逻辑已删除 —— 超时时服务端是否已提交未知,重放会重复创建或重复追加,现在改为 fail closed,要求先回读确认。`CONTENT_TRUNCATED` 错误码随之消失。
|
||||
|
||||
`--index` 与自动分片互斥:每片产生的 block 数不确定,第 2 片的插入位置不可知,因此内容超过上限且带 `--index` 时会直接报错(`doc_write_index_with_chunking`)。
|
||||
|
||||
### 输出格式
|
||||
|
||||
@@ -110,6 +123,7 @@ Flags:
|
||||
|------|------|
|
||||
| `nodeId` | 文档节点 ID,可用于后续读取或追加 |
|
||||
| `chunksWritten` | 实际写入的分片数(1 = 单次写入) |
|
||||
| `degradations` | 改变了渲染结构的切分点清单;**缺省即表示文档与输入完全一致**。每项含 `kind`(如 `table_split` / `code_block_split` / `paragraph_split`)、`line`、`detail`,以及 `injectedPrefix` / `injectedSuffix`(本次为保持结构而注入的文本,例如重发的表头行) |
|
||||
|
||||
### 内容完整性验证(必读)
|
||||
|
||||
@@ -124,22 +138,22 @@ CLI **不会**自动执行回读验证。**你必须在文档写入完成后主
|
||||
### 进度输出示例
|
||||
|
||||
```
|
||||
[INFO] 内容较长 (45000 字符),自动分片写入...
|
||||
[INFO] 已创建空文档 (nodeId=abc123),开始分片写入...
|
||||
[INFO] 写入分片 (1/3),15000 字符...
|
||||
[INFO] 写入分片 (2/3),15000 字符...
|
||||
[INFO] 写入分片 (3/3),15000 字符...
|
||||
[INFO] 内容较长 (72000 字符),自动分片写入...
|
||||
[INFO] [WARN] 内容过长已分片:表格被拆成多个表格,后续分片重复表头行与分隔行(第 1420 行)
|
||||
[INFO] 写入分片 (1/3),29987 字符 (overwrite)...
|
||||
[INFO] 写入分片 (2/3),29994 字符, preview=[| 姓名 | 部门 |...]...
|
||||
[INFO] 写入分片 (3/3),12030 字符, preview=[| 姓名 | 部门 |...]...
|
||||
[INFO] 全部 3 个分片写入完成
|
||||
{"success": true, "nodeId": "abc123", "chunksWritten": 3}
|
||||
{"success": true, "nodeId": "abc123", "chunksWritten": 3, "degradations": [{"kind": "table_split", "line": 1420, ...}]}
|
||||
```
|
||||
|
||||
### CONTENT_TRUNCATED 错误
|
||||
### 分片超时(提交状态未知)
|
||||
|
||||
当分片写入持续超时且减半到最小阈值仍失败时,返回 `CONTENT_TRUNCATED` 错误码。应对策略:
|
||||
某片写入超时时,服务端是否已提交无法判断。CLI **不会**自动重试(重放会重复追加),而是 fail closed 返回 `doc_write_commit_unknown`,并在 `details` 里给出 `chunksWritten` / `chunksTotal` / `failedStage`,以及该次分片的 `degradations`。应对策略:
|
||||
|
||||
1. 检查网络和后端服务状态
|
||||
2. 已写入的部分内容可通过 `dws doc read --node <NODE_ID>` 查看
|
||||
3. 从断点处手动用 `dws doc update --mode append` 继续追加
|
||||
1. 先 `dws doc read --node <NODE_ID>` 回读,确认实际写到哪一片
|
||||
2. 只有确认服务端未提交时才重新执行
|
||||
3. 从断点处用 `dws doc update --mode append` 继续追加;注意若 `degradations` 里有 `table_split`,续写的第一片需要自己带上表头行
|
||||
|
||||
## 长 Markdown 写入
|
||||
|
||||
@@ -193,7 +207,7 @@ EOF
|
||||
| 从返回中提取 | 用于 |
|
||||
|-------------|------|
|
||||
| `success` + `chunksWritten` | 判断是否需要回读补救(`chunksWritten > 1` 时重点查章节顺序) |
|
||||
| 错误码 `CONTENT_TRUNCATED` | 触发 [`./doc-read.md`](./doc-read.md) 查断点 + 再次 `update --mode append` |
|
||||
| `reason` 为 `doc_write_commit_unknown` | 触发 [`./doc-read.md`](./doc-read.md) 查断点,确认未提交后再 `update --mode append` |
|
||||
|
||||
## 常用模板
|
||||
|
||||
|
||||
@@ -369,14 +369,14 @@ dws doc read --node <nodeId>
|
||||
- 开头摘要、关键章节、表格表头、末尾章节都存在。
|
||||
- 回读文本顺序和临时 Markdown 一致。
|
||||
- 没有把字面量 `\n` 渲染成一整行。
|
||||
- 如果返回 `chunksWritten > 1`,检查分片边界没有破坏表格、代码块或列表。
|
||||
- 如果返回 `chunksWritten > 1`,看 `degradations`:为空即表示分片没有改变渲染结构,无需人工核对边界;非空时按其中的 `kind` 与 `line` 定点检查(如 `table_split` 表示该表被拆成多张、每张带重发的表头)。
|
||||
- 最终回复必须给用户 `docUrl`;如果只拿到 `nodeId`,说明链接字段未返回,并报告已尝试 `doc info`。
|
||||
|
||||
## 缺失补救
|
||||
|
||||
DWS 写入管道会自动处理长内容分片。只有出现以下情况才手工补片:
|
||||
|
||||
- 返回 `CONTENT_TRUNCATED`
|
||||
- 返回 `doc_write_commit_unknown`(分片超时,提交状态未知)
|
||||
- 命令超时或只写入部分分片
|
||||
- 回读发现后半段缺失、章节乱序或表格损坏
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# 家校应用 (edu-app) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### message (消息管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` |
|
||||
|
||||
> `--target-role`: guardian(家长) / student(学生)
|
||||
> `--status`: 0(未处理) / 1(已处理)
|
||||
|
||||
### task (任务管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) |
|
||||
| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) |
|
||||
| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) |
|
||||
|
||||
> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA
|
||||
|
||||
### report (成绩单管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) |
|
||||
| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) |
|
||||
| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` |
|
||||
| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` |
|
||||
| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` |
|
||||
|
||||
### notice (通知管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `notice create` | 创建并发布通知 | `--identifer`, `--content` |
|
||||
| `notice get` | 查询通知详情 | `--notice-id` |
|
||||
| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) |
|
||||
| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` |
|
||||
| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` |
|
||||
| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` |
|
||||
| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` |
|
||||
|
||||
> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`;建议格式 `orgId-staffId-UUID`
|
||||
> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收
|
||||
> `notice list-by-teacher --status` / `notice list-by-student --status` 用于筛选通知状态
|
||||
|
||||
### circle (班级圈)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` |
|
||||
|
||||
> `--target-role`: guardian(家长视角) / student(学生视角)
|
||||
> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。
|
||||
|
||||
### card (打卡管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `card update` | 修改打卡任务的标题或内容(仅老师且为创建者) | `--card-id`, `--identifier`, (`--title` 或 `--content` 至少一个) |
|
||||
| `card end` | 提前结束打卡任务(仅老师且为创建者) | `--card-id` |
|
||||
| `card list` | 查询孩子/本人打卡列表(含进行中与已完结) | `--status` |
|
||||
| `card user-statistic` | 查询班级已完成/未完成人员(仅老师/班主任) | `--card-id`, `--task-code`, `--class-id` |
|
||||
| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` |
|
||||
|
||||
> `--status`: FINISH(已完结) / UNFINISH(进行中)
|
||||
> `--identifier` 建议格式 `orgId-staffId-UUID`,用于幂等去重
|
||||
> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断
|
||||
> `card finish-info` 当 `targetRole=guardian` 时,可传 `--student-id` 指定查看某个孩子的进度
|
||||
|
||||
### homework (作业管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` |
|
||||
| `homework get` | 查询作业详情 | `--homework-id` |
|
||||
| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) |
|
||||
| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` |
|
||||
| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` |
|
||||
| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` |
|
||||
| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` |
|
||||
| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` |
|
||||
| `homework submit` | 提交作业 | `--hw-content-detail-id` |
|
||||
| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` |
|
||||
| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` |
|
||||
|
||||
> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题
|
||||
> `--submit-types` / `--hw-type` / `--publish-type` 控制提交方式与作业类型
|
||||
> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id`
|
||||
|
||||
### diploma (奖状管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` |
|
||||
| `diploma get` | 查询奖状详情 | `--diploma-id` |
|
||||
| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) |
|
||||
| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` |
|
||||
| `diploma detail` | 查询奖状接收详情 | `--diploma-id` |
|
||||
| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` |
|
||||
| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` |
|
||||
| `diploma read` | 标记奖状为已读 | `--diploma-id` |
|
||||
| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` |
|
||||
|
||||
> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板
|
||||
|
||||
## 危险操作
|
||||
|
||||
以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3):
|
||||
|
||||
| 命令 | 后果 |
|
||||
|------|------|
|
||||
| `notice delete --notice-id <id> --yes` | 删除通知,家长/学生侧不可恢复 |
|
||||
| `homework delete --homework-id <id> --yes` | 删除作业及其提交记录 |
|
||||
| `diploma delete --diploma-id <id> --yes` | 删除已颁发的奖状 |
|
||||
|
||||
其余命令均为读或普通写操作,不需要 `--yes`。
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"消息/消息摘要" → message summary-list
|
||||
用户说"家校任务/待办任务" → task 子命令
|
||||
用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`)
|
||||
用户说"成绩/成绩单" → report 子命令
|
||||
用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`)
|
||||
用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`)
|
||||
用户说"班级圈/成长记录/学生动态" → circle posts
|
||||
用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令
|
||||
|
||||
关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源)
|
||||
关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结)
|
||||
关键区分: diploma(奖状/表彰) vs report(成绩单)
|
||||
老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
### 老师场景
|
||||
1. 查看发布的任务 → `task publish-list --need-statistic -f json`
|
||||
2. 查看某班全部任务 → `task all-list --biz-id <classId>`
|
||||
3. 查看成绩单 → `report by-teacher --status 1`
|
||||
4. 查看班级成绩明细 → `report by-class --report-id <id> --class-id <classId>`
|
||||
5. 修改打卡标题/内容 → `card update --card-id <cardId> --identifier <id> --title "新标题"`
|
||||
6. 提前结束打卡 → `card end --card-id <cardId>`
|
||||
7. 查看某班打卡完成情况 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId> --finish`
|
||||
8. 查看某班未打卡人员 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId>`
|
||||
9. 查看某打卡完成进度 → `card finish-info --card-id <cardId> --card-biz-id <cardBizId>`
|
||||
10. 发布作业 → `homework create --identifier <orgId-staffId-UUID> --hw-content "第三章习题" --class-ids <classId>`
|
||||
11. 查看作业提交统计 → `homework submit-statistics --homework-id <id> --class-id <classId>`
|
||||
12. 批改作业写评语 → `homework create-comment --hw-content-detail-id <id> --comment "写得很好"`
|
||||
13. 删除作业 → `homework delete --homework-id <id> --yes`
|
||||
14. 发布通知 → `notice create --identifer <orgId-staffId-UUID> --content "明天放假" --class-ids <classId> --is-signed true`
|
||||
15. 查看通知签收情况 → `notice confirm-status --notice-id <id> --class-id <classId>`
|
||||
16. 删除通知 → `notice delete --notice-id <id> --yes`
|
||||
17. 颁发奖状 → `diploma create --identifier <orgId-staffId-UUID> --content "三好学生" --user-name <老师姓名> --class-ids <classId>`
|
||||
18. 查看奖状阅读统计 → `diploma statistics --diploma-id <id>`
|
||||
19. 删除奖状 → `diploma delete --diploma-id <id> --yes`
|
||||
|
||||
### 家长场景
|
||||
1. 查看孩子待办 → `task student-list --students '[{"userId":"<uid>","bizId":"<classId>"}]'`
|
||||
2. 确认通知 → `notice confirm --notice-id <id> --student-id <uid>`
|
||||
3. 查看孩子收到的通知 → `notice list-by-student --student-id <uid> --class-id <classId>`
|
||||
4. 查看孩子班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role guardian`
|
||||
5. 查看孩子进行中打卡 → `card list --status UNFINISH`
|
||||
6. 查看孩子某打卡进度 → `card finish-info --card-id <cardId> --card-biz-id <cardBizId>`
|
||||
7. 查看孩子作业列表 → `homework list-by-student --student-id <uid> --class-id <classId> --user-name <家长姓名>`
|
||||
8. 查看孩子收到的奖状 → `diploma list-by-student --student-id <uid> --class-id <classId>`
|
||||
|
||||
### 学生场景
|
||||
1. 查看自己的班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role student`
|
||||
2. 提交作业 → `homework submit --hw-content-detail-id <id> --content "已完成"`
|
||||
3. 查看某份作业详情 → `homework student-detail --homework-id <id> --student-id <uid> --class-id <classId>`
|
||||
4. 标记奖状已读 → `diploma read --diploma-id <id>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id |
|
||||
| `dws edu-contact class students` | `userId` | task student-list 的 students.userId |
|
||||
| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid |
|
||||
| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id |
|
||||
| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id |
|
||||
| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id |
|
||||
| `task publish-list` | `taskCode` | card user-statistic 的 --task-code |
|
||||
| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id |
|
||||
| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id |
|
||||
| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id |
|
||||
| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id |
|
||||
@@ -1,74 +0,0 @@
|
||||
# 家校通讯录 (edu-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### school (学校/组织管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `school roles` | 查询用户在组织内的身份 | 无 |
|
||||
| `school structure` | 查询学校组织架构 | 无 |
|
||||
| `school periods` | 查询学校学段信息 | 无 |
|
||||
| `school type` | 查询学校组织类型 | 无 |
|
||||
| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) |
|
||||
| `school class-list` | 查询学校所有班级列表 | 无 |
|
||||
|
||||
### class (班级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class detail` | 查询班级详情 | `--dept-id` |
|
||||
| `class students` | 查询班级学生信息 | `--dept-id` |
|
||||
| `class teachers` | 查询班级老师列表 | `--dept-id` |
|
||||
| `class same-name` | 查询班级内同名学生 | `--dept-id` |
|
||||
| `class user-role` | 查询用户在班级内的角色 | `--dept-id` |
|
||||
| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` |
|
||||
| `class headmaster` | 根据班级名查询班主任 | `--class-name` |
|
||||
| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` |
|
||||
| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` |
|
||||
| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` |
|
||||
| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` |
|
||||
| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` |
|
||||
| `class update-info` | 更新班级信息 | `--class-id` |
|
||||
| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` |
|
||||
| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` |
|
||||
| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` |
|
||||
| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` |
|
||||
| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` |
|
||||
| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` |
|
||||
|
||||
### family (家庭关系查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `family children` | 查询家长的孩子信息 | 无 |
|
||||
| `family parents` | 查询学生的家长信息 | 无 |
|
||||
|
||||
### teacher (教师管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `teacher classes` | 查询老师管理的班级列表 | 无 |
|
||||
| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"学校/组织/学段/组织架构" → school 子命令
|
||||
用户说"班级/学生/教师/班主任" → class 子命令
|
||||
用户说"家长/孩子/家庭关系" → family 子命令
|
||||
用户说"任教/科目" → teacher update-course
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查询角色 → `school roles`
|
||||
2. 查看班级列表 → `school class-list`(提取 deptId)
|
||||
3. 查看班级详情 → `class detail --dept-id <deptId>`
|
||||
4. 查看学生列表 → `class students --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `school class-list` | `deptId` | class 子命令的 --dept-id |
|
||||
| `class students` | `userId` | update-student/delete-students 的 --student-user-id |
|
||||
| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id |
|
||||
@@ -1,68 +0,0 @@
|
||||
# 家庭群 (edu-familygroup) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### group (家庭群查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` |
|
||||
| `group list-children` | 查询家长绑定的孩子列表 | `--uid` |
|
||||
|
||||
### manage (家庭群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `manage create` | 创建家庭群 | `--uid`, `--children` |
|
||||
| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` |
|
||||
| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) |
|
||||
| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"家庭群存在/有没有家庭群" → group check-exists
|
||||
用户说"孩子列表/我的孩子" → group list-children
|
||||
用户说"创建家庭/建群" → manage create
|
||||
用户说"邀请家长/拉家长入群" → manage invite-parent
|
||||
用户说"添加孩子/加娃" → manage add-child
|
||||
用户说"应用权限/小天地/学习视频" → manage toggle-app
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查家庭群是否存在 → `group check-exists --uid <uid> --group-name <name>`
|
||||
2. 如不存在,创建家庭群 → `manage create --uid <uid> --children '<json>'`
|
||||
3. 查看已绑定孩子 → `group list-children --uid <uid>`
|
||||
4. 邀请其他家长 → `manage invite-parent --org-id <orgId> --uid <uid> --mobile <phone>`
|
||||
5. 添加孩子 → `manage add-child --org-id <orgId> --uid <uid> --name <name> --mobile <phone>`
|
||||
6. 管理应用权限 → `manage toggle-app --org-id <orgId> --uid <uid> --child-staff-id <id> --app-type XIAOTIANDI --open true`
|
||||
|
||||
## 参数说明
|
||||
|
||||
### manage create --children 格式
|
||||
|
||||
```json
|
||||
[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]
|
||||
```
|
||||
|
||||
每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。
|
||||
|
||||
### manage add-child --students 格式
|
||||
|
||||
```json
|
||||
[{"schoolOrgId":111,"studentStaffId":"stu001"}]
|
||||
```
|
||||
|
||||
每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。
|
||||
|
||||
### manage toggle-app --app-type 可选值
|
||||
|
||||
- `XIAOTIANDI`:小天地(学生圈)
|
||||
- `LEARNING_VIDEO`:学习视频
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id |
|
||||
| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id |
|
||||
| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid |
|
||||
@@ -1,55 +0,0 @@
|
||||
# 家校群 (edu-group) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### student-group (师生群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `student-group info` | 查询班级师生群信息 | `--dept-id` |
|
||||
| `student-group exists` | 检查是否已创建师生群 | `--dept-id` |
|
||||
| `student-group members` | 查询师生群成员列表 | `--dept-id` |
|
||||
| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` |
|
||||
| `student-group conversation` | 查询班级群会话详情 | `--dept-id` |
|
||||
| `student-group create` | 创建班级师生群 | `--dept-id` |
|
||||
| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` |
|
||||
|
||||
### class-group (班级群会话管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` |
|
||||
| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` |
|
||||
| `class-group exists` | 检查班级群是否存在 | `--dept-id` |
|
||||
| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` |
|
||||
|
||||
### batch (批量操作)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` |
|
||||
| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` |
|
||||
| `batch create-student-groups` | 批量创建师生群 | 无 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"师生群/学生群" → student-group 子命令
|
||||
用户说"班级群/群会话" → class-group 子命令
|
||||
用户说"批量/一键操作" → batch 子命令
|
||||
|
||||
关键区分: student-group(师生群) vs class-group(班级群会话管理)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查群是否存在 → `student-group exists --dept-id <deptId>`
|
||||
2. 如不存在,创建 → `student-group create --dept-id <deptId>`
|
||||
3. 查看成员 → `student-group members --dept-id <deptId>`
|
||||
4. 获取会话ID → `class-group conversation-id --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id |
|
||||
| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid |
|
||||
| `batch check-student-group` | 未创建群的班级 | batch create-student-groups |
|
||||
@@ -24,7 +24,7 @@
|
||||
- `dws report outbox list` = 列出**我发出**的日报(我创建或提交的)。
|
||||
- `dws report entry get --report-id <reportId>` = 读取单份日报正文 + 钉钉跳转链接。
|
||||
- `dws report entry stats --report-id <reportId>` = 读取单份日报的已读统计。
|
||||
- `dws report entry submit --template-id ... --contents-file ...` = 按模版提交一份新日报。
|
||||
- `dws report entry submit --template-id ... --contents-file ... --to-user-ids ...` = 按模版提交一份新日报(--to-user-ids 必填:无接收人的日志对任何人都不可见)。
|
||||
- `dws report template list` = 列出可用日报模版。
|
||||
- `dws report template get --name "<模版名>"` = 读取单个模版的字段定义(contents 拼装来源)。
|
||||
|
||||
@@ -127,7 +127,7 @@ CLI 列表命令只返回 JSON-first 数据,不把 Markdown 表作为裸文本
|
||||
|
||||
1. `dws report template list --format json` — 取 `report_template_id` 与可见模版名
|
||||
2. `dws report template get --name "<模版名>" --format json` — 取 `result.report_template_fields[]`,每项含 `field_name` / `field_sort` / `field_type`
|
||||
3. `dws report entry submit --template-id <id> --contents-file <tmp.json> --format json` — contents 数组按上面「字段映射」严格对齐第 2 步:`field_name → key`,`field_sort → sort`,`field_type → type`,再填 `content` 与 `contentType`;CLI 提交成功后会自动反查详情并追加钉钉打开链接字段,返回中直接取 `reportId` 与 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl`
|
||||
3. `dws report entry submit --template-id <id> --contents-file <tmp.json> --to-user-ids <userId1>,<userId2> --format json` — contents 数组按上面「字段映射」严格对齐第 2 步:`field_name → key`,`field_sort → sort`,`field_type → type`,再填 `content` 与 `contentType`;`--to-user-ids` 必填:无接收人的提交服务端仍返回成功但日志对任何人都不可见;CLI 提交成功后会自动反查详情并追加钉钉打开链接字段,返回中直接取 `reportId` 与 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl`
|
||||
4. 仅当第 3 步返回中缺少 `dingtalkOpenUrl` 时,执行 `dws report entry get --report-id <reportId> --format json` 补取 `result.url`(`dingtalk://...` 协议深链接)。final reply 中优先使用 `dingtalkOpenMarkdownLink`,否则用 `[在钉钉中查看日志](dingtalkOpenUrl)`。**禁止把 raw `dingtalk://...` URL 原样写进回复**,必须包成 markdown link 让用户可点击跳转钉钉客户端
|
||||
|
||||
跳步风险(已实证):
|
||||
@@ -136,6 +136,7 @@ CLI 列表命令只返回 JSON-first 数据,不把 Markdown 表作为裸文本
|
||||
- 跳过第 2 步用 LLM 经验编 `key` 名 → 服务端返回 `PARAM_ERROR`,且**不告诉你哪个字段错**;服务端 PARAM_ERROR 信号弱,事后无法定位,**只能靠前置 schema 同步避免**;
|
||||
- 未取到 `dingtalkOpenUrl` 且不补查 `entry get` → 用户拿不到跳转链接,无法在钉钉客户端打开刚提交的日志查看 / 修改;
|
||||
- 用 `--contents` 直传长 JSON → shell 引号转义破坏 JSON → `INPUT_INVALID_JSON`。**长内容务必走 `--contents-file <path>` 或 `--contents -` (stdin)**。
|
||||
- 不传 `--to-user-ids` → 服务端仍返回成功但日志对任何接收人都不可见;CLI 已强制必填,缺 flag 或传空值都会被拒绝
|
||||
- contents JSON 大小限制为 10MB,**不支持分批次提交**。超过限制需精简内容或拆分为多个独立日志提交。
|
||||
|
||||
推荐:Agent 在多轮场景中应在内存里持久化第 1/2 步的结果,避免每轮重新跑。
|
||||
@@ -167,22 +168,22 @@ Usage:
|
||||
dws report entry submit [flags]
|
||||
Example:
|
||||
# 推荐:长内容走文件,避免 shell 引号问题
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --format json
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --to-user-ids <userId1>,<userId2> --format json
|
||||
|
||||
# stdin 输入
|
||||
cat report.json | dws report entry submit --template-id <templateId> --contents - --format json
|
||||
cat report.json | dws report entry submit --template-id <templateId> --contents - --to-user-ids <userId1> --format json
|
||||
|
||||
# 内联(短内容)
|
||||
dws report entry submit --template-id <templateId> \
|
||||
--contents '[{"key":"今日完成","sort":"0","content":"完成了需求评审","contentType":"markdown","type":"1"}]' \
|
||||
--format json
|
||||
--to-user-ids <userId1> --format json
|
||||
Flags:
|
||||
--template-id string 日志模版 ID (必填),从 template list 返回中取
|
||||
--contents string 日志内容 JSON 数组 (必填,或用 --contents-file);传 `-` 表示从 stdin 读取
|
||||
--contents-file string 从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容)
|
||||
--dd-from string 创建来源标识 (默认 dws)
|
||||
--to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true)
|
||||
--to-user-ids string 接收人 userId,逗号分隔 (可选)
|
||||
--to-user-ids string 接收人 userId,逗号分隔 (必填);无接收人的日志提交后对任何人都不可见
|
||||
```
|
||||
|
||||
|
||||
@@ -316,8 +317,8 @@ dws report template list --format json
|
||||
# 2. 按名称读取模版字段定义
|
||||
dws report template get --name "日报" --format json
|
||||
|
||||
# 2b. 提交日志(从步骤 1/2 取 templateId 与 contents 字段)— 推荐 --contents-file 传入避免 shell 引号
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --format json
|
||||
# 2b. 提交日志(从步骤 1/2 取 templateId 与 contents 字段)— 推荐 --contents-file 传入避免 shell 引号;--to-user-ids 必填
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --to-user-ids <userId1>,<userId2> --format json
|
||||
# submit 成功会自动反查详情并追加 dingtalkOpenMarkdownLink / dingtalkOpenUrl;
|
||||
# final reply 直接使用 dingtalkOpenMarkdownLink: [在钉钉中查看日志](dingtalk://...)
|
||||
|
||||
|
||||
@@ -364,14 +364,14 @@ dws doc +create --name "<文档名>" --content "短内容" --doc-format markdown
|
||||
- 开头摘要、关键章节、表格表头、末尾章节都存在。
|
||||
- 回读文本顺序和临时 Markdown 一致。
|
||||
- 没有把字面量 `\n` 渲染成一整行。
|
||||
- 如果返回 `chunksWritten > 1`,检查分片边界没有破坏表格、代码块或列表。
|
||||
- 如果返回 `chunksWritten > 1`,看 `degradations`:为空即表示分片没有改变渲染结构,无需人工核对边界;非空时按其中的 `kind` 与 `line` 定点检查(如 `table_split` 表示该表被拆成多张、每张带重发的表头)。
|
||||
- 最终回复必须给用户 `docUrl`;如果只拿到 `nodeId`,说明链接字段未返回,并报告已尝试 `doc info`。
|
||||
|
||||
## 缺失补救
|
||||
|
||||
DWS 写入管道会自动处理长内容分片。只有出现以下情况才手工补片:
|
||||
|
||||
- 返回 `CONTENT_TRUNCATED`
|
||||
- 返回 `doc_write_commit_unknown`(分片超时,提交状态未知)
|
||||
- 命令超时或只写入部分分片
|
||||
- 回读发现后半段缺失、章节乱序或表格损坏
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-misc
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile/家校应用/家校通讯录/家校群/高校通讯录。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile/家校任务/班级消息摘要/学校组织架构/班级列表/师生群/班级群/高校院系部门/师生员工管理 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -37,11 +37,6 @@ metadata:
|
||||
| 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) |
|
||||
| PAT 授权 / 行为权限 / scope 授权 / 一次性授权 / 会话授权 / 永久授权 / 授权浏览器策略 | PAT 行为授权与本地浏览器策略 | `dws pat` | [pat.md](references/pat.md) |
|
||||
| 切换组织 / 换组织 / 跨组织 / 多组织 / profile / 看登录了哪些组织 | 多组织 / profile 管理与跨组织取数 | `dws profile` / `dws auth` / `--profile` | [profile.md](references/profile.md) |
|
||||
| 家校应用 / 班级消息摘要 / 家校任务(教育场景) | 家校应用(教育版) | `dws edu-app` | [edu-app.md](references/edu-app.md) |
|
||||
| 家校通讯录 / 学校组织架构 / 班级列表(教育场景) | 家校通讯录(教育版) | `dws edu-contact` | [edu-contact.md](references/edu-contact.md) |
|
||||
| 家校群 / 师生群 / 班级群(教育场景) | 家校群(师生群)查询、创建、解散 | `dws edu-group` | [edu-group.md](references/edu-group.md) |
|
||||
| 家庭群 / 家长邀请 / 孩子管理 / 学生应用权限(教育场景) | 家庭群查询/创建、孩子管理、家长邀请 | `dws edu-familygroup` | [edu-familygroup.md](references/edu-familygroup.md) |
|
||||
| 高校通讯录 / 高校组织架构 / 院系部门 / 师生员工管理(高校场景) | 高校通讯录:部门与师生员工管理、搜索、统计、升级 | `dws college-contact` | [college-contact.md](references/college-contact.md) |
|
||||
| 宜搭 / AI应用脚本 / 财务辅助脚本(未产品化) | **无**稳定命令面;仅仓库内辅助脚本 | (非默认路由) | [unsupported-scripts.md](references/unsupported-scripts.md) |
|
||||
|
||||
## 说明
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;CSV 附件、群聊导出日志、系统日志或聊天记录核验不属于 OA 日志,应转到文件/群聊/表格相关 skill;其余歧义先按 doc/report 分歧澄清<br>1. 第一条有效查询必须按视角选择新命令:用户说「我发过 / 我创建 / 已发送」→ `report outbox list --cursor 0 --size 20 --format json`;用户说「收到 / 收件箱 / 别人发给我 / 最近收到」→ `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`;不要先生成 `report list` / `report sent` 等 deprecated alias,也不要用 inbox 代替 outbox<br>2. 时间 flag 只允许 `--start` / `--end`;裸日期必须展开完整 ISO + `+08:00`;禁止 `--start-date` / `--end-date` / `--date`、UTC `Z`、`date -u`;用户只说「最近 / 近期 / 最近收到 / 最近一周」默认最近 7 天;`--size` 最大 20,更多结果按 `cursor` 分页,禁止传 50/100<br>3. 按发件人查收件箱时,先 `aisearch person --query "<姓名>" --dimension name --format json` 取 `userId/staffId`,再给 `inbox list` 加 `--sender-user-ids <id>`;如果列表中找不到目标发件人或目标标记日志,必须说明不可见 / 未找到,不得改选其他发件人或其他日志<br>4. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表;面向用户展示列表时基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`,不要把日志 ID 作为主列,缺失字段不编造<br>5. 用户要正文、详情、汇总、总结多篇日志或检查内容时,必须对选中的每篇日志逐条执行 `report entry get --report-id <reportId> --format json`;例如“总结最近收到的 5 篇”应先 list 取前 5 篇(不足 5 篇按实际数量说明),再执行相同数量的 `entry get`;用户要统计 / 已读情况时执行 `report entry stats --report-id <reportId> --format json`<br>**不要把 inbox list/outbox list 当正文接口**;查询正文必须补 `entry get`<br>**不要再生成** `report list` / `report sent` / `report detail` / `report stats`(deprecated alias,仍能跑但会打 stderr 警告) |
|
||||
| generate-daily-report | 1. 按[「多源并行采集」](./report-conventions.md#多源并行采集公共模式)执行(时间=今日)<br>2. 交叉汇总并把日报内容写入临时文件 `<tmp>.md`(UTF-8,真实换行)<br>3. **创建文档**:`doc create --name "<日报名>" --content-file <tmp>.md`(> 200KB 按 write-doc 兜底(见 `dingtalk-doc/references/04-document.md`) 走 create 空 → 循环 update) |
|
||||
| generate-weekly-report | 1. 按[「多源并行采集」](./report-conventions.md#多源并行采集公共模式)执行(时间=本周)<br>2. 交叉对比并把周报内容写入临时文件 `<tmp>.md`<br>3. **创建文档**:`doc create --name "<周报名>" --content-file <tmp>.md`(兜底同上) |
|
||||
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按[「多源并行采集」](./report-conventions.md#多源并行采集公共模式)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --format json` → CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
|
||||
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按[「多源并行采集」](./report-conventions.md#多源并行采集公共模式)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --to-user-ids <userId1>,<userId2> --format json` → `--to-user-ids` 必填:无接收人的提交服务端仍返回成功但日志对任何人都不可见;CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
|
||||
| generate-monthly-report | 1. 按[「多源并行采集」](./report-conventions.md#多源并行采集公共模式)执行(时间=当月)<br>2. `report outbox list --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
|
||||
| generate-topic-report | 1. 提取主题关键词;推断时间范围("最近"默认近 30 天)<br>2. 按[「多源并行采集」](./report-conventions.md#多源并行采集公共模式)执行<br>3. 按时间线排列,交叉归纳核心结论/决策/行动项/未解决问题/演进脉络,并把内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<报告名>" --content-file <tmp>.md`(兜底同上) |
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# 高校通讯录 (college-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### dept (部门管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 |
|
||||
| `dept get-detail` | 查询部门详情 | `--dept-id` |
|
||||
| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` |
|
||||
| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` |
|
||||
| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` |
|
||||
| `dept update` | 更新部门 | `--dept-id`, `--dept-type` |
|
||||
| `dept delete` | 删除部门 ⚠️ | `--dept-id` |
|
||||
| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` |
|
||||
| `dept overview` | 查询高校概览统计 | 无 |
|
||||
|
||||
### employee (员工管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `employee get-detail` | 查询员工详情 | `--staff-id` |
|
||||
| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` |
|
||||
| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) |
|
||||
| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` |
|
||||
| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` |
|
||||
| `employee send-active-sms` | 发送激活短信 | `--dept-id` |
|
||||
| `employee list-employees` | 查询部门员工列表 | `--dept-id` |
|
||||
| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` |
|
||||
| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` |
|
||||
| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 |
|
||||
| `employee start-upgrade` | 启动高校通讯录升级 | 无 |
|
||||
|
||||
### alumni (校友管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` |
|
||||
| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` |
|
||||
| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` |
|
||||
| `alumni query` | 查询单个校友详情 | `--staff-id` |
|
||||
| `alumni search` | 搜索校友 | `--keyword` |
|
||||
| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` |
|
||||
| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` |
|
||||
| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` |
|
||||
| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) |
|
||||
| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) |
|
||||
| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` |
|
||||
| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `alumni create-group` | 创建校友群 | `--alumni-dept-id` |
|
||||
| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` |
|
||||
| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 |
|
||||
| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` |
|
||||
| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) |
|
||||
|
||||
### graduate (毕业年级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 |
|
||||
| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) |
|
||||
| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` |
|
||||
| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) |
|
||||
| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) |
|
||||
| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 |
|
||||
| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` |
|
||||
| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) |
|
||||
| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) |
|
||||
| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) |
|
||||
| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) |
|
||||
| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` |
|
||||
| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) |
|
||||
|
||||
### group (规则管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) |
|
||||
| `group get-group-rule-schedule` | 查询规则调度 | 无参数 |
|
||||
| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) |
|
||||
| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) |
|
||||
| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` |
|
||||
| `group enable-group-rule` | 启用规则 | `--rule-id` |
|
||||
| `group disable-group-rule` | 停用规则 | `--rule-id` |
|
||||
| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) |
|
||||
| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 |
|
||||
|
||||
## 常用参数说明
|
||||
|
||||
- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工)
|
||||
- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业)
|
||||
- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表
|
||||
- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序
|
||||
- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false)
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"高校架构/组织架构/学院/系/部门" → dept 子命令
|
||||
用户说"搜人/找某某老师/找某某同学" → `dept search`
|
||||
用户说"师生/教职工/学生/员工/辅导员" → employee 子命令
|
||||
用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms`
|
||||
用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade`
|
||||
用户说"校友/校友会/校友部门/添加校友" → alumni 子命令
|
||||
用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令
|
||||
用户说"群规则/建群规则/自动建群" → group 子命令
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查标准架构 → `dept get-standard-structure`(提取 deptId)
|
||||
2. 查看部门详情 → `dept get-detail --dept-id <deptId>`
|
||||
3. 查看部门员工 → `employee list-employees --dept-id <deptId>`
|
||||
4. 查看员工详情 → `employee get-detail --staff-id <staffId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id |
|
||||
| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids |
|
||||
| `dept create` | `deptId` | update/delete 的 --dept-id |
|
||||
| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id |
|
||||
| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id |
|
||||
| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id |
|
||||
| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id |
|
||||
|
||||
## 危险操作
|
||||
|
||||
- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。
|
||||
@@ -1,198 +0,0 @@
|
||||
# 家校应用 (edu-app) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### message (消息管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` |
|
||||
|
||||
> `--target-role`: guardian(家长) / student(学生)
|
||||
> `--status`: 0(未处理) / 1(已处理)
|
||||
|
||||
### task (任务管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) |
|
||||
| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) |
|
||||
| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) |
|
||||
|
||||
> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA
|
||||
|
||||
### report (成绩单管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) |
|
||||
| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) |
|
||||
| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` |
|
||||
| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` |
|
||||
| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` |
|
||||
|
||||
### notice (通知管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `notice create` | 创建并发布通知 | `--identifer`, `--content` |
|
||||
| `notice get` | 查询通知详情 | `--notice-id` |
|
||||
| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) |
|
||||
| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` |
|
||||
| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` |
|
||||
| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` |
|
||||
| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` |
|
||||
|
||||
> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`
|
||||
> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收
|
||||
|
||||
### circle (班级圈)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` |
|
||||
|
||||
> `--target-role`: guardian(家长视角) / student(学生视角)
|
||||
> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。
|
||||
|
||||
### card (打卡管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `card update` | 修改打卡标题或内容(仅创建者) | `--card-id`, `--identifier`, `--title`/`--content` |
|
||||
| `card end` | 提前结束打卡任务(仅创建者) | `--card-id` |
|
||||
| `card list` | 查询打卡列表(学生/家长) | `--status`(FINISH/UNFINISH) |
|
||||
| `card user-statistic` | 查询班级打卡完成/未完成人员(老师/班主任) | `--card-id`, `--task-code`, `--class-id` |
|
||||
| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` |
|
||||
|
||||
> `card list --status`: FINISH(已完结) / UNFINISH(进行中)
|
||||
> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断
|
||||
> `card finish-info --student-id`: 当 targetRole 为 guardian 时,用于指定查看某个孩子的进度
|
||||
|
||||
### homework (作业管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` |
|
||||
| `homework get` | 查询作业详情 | `--homework-id` |
|
||||
| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) |
|
||||
| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` |
|
||||
| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` |
|
||||
| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` |
|
||||
| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` |
|
||||
| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` |
|
||||
| `homework submit` | 提交作业 | `--hw-content-detail-id` |
|
||||
| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` |
|
||||
| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` |
|
||||
|
||||
> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题
|
||||
> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id`
|
||||
|
||||
### diploma (奖状管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` |
|
||||
| `diploma get` | 查询奖状详情 | `--diploma-id` |
|
||||
| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) |
|
||||
| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` |
|
||||
| `diploma detail` | 查询奖状接收详情 | `--diploma-id` |
|
||||
| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` |
|
||||
| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` |
|
||||
| `diploma read` | 标记奖状为已读 | `--diploma-id` |
|
||||
| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` |
|
||||
|
||||
> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板
|
||||
|
||||
## 危险操作
|
||||
|
||||
以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3):
|
||||
|
||||
| 命令 | 后果 |
|
||||
|------|------|
|
||||
| `notice delete --notice-id <id> --yes` | 删除通知,家长/学生侧不可恢复 |
|
||||
| `homework delete --homework-id <id> --yes` | 删除作业及其提交记录 |
|
||||
| `diploma delete --diploma-id <id> --yes` | 删除已颁发的奖状 |
|
||||
|
||||
其余命令均为读或普通写操作,不需要 `--yes`。
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"消息/消息摘要" → message summary-list
|
||||
用户说"家校任务/待办任务" → task 子命令
|
||||
用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`)
|
||||
用户说"成绩/成绩单" → report 子命令
|
||||
用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`)
|
||||
用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`)
|
||||
用户说"班级圈/成长记录/学生动态" → circle posts
|
||||
用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令
|
||||
|
||||
关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源)
|
||||
关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结)
|
||||
关键区分: diploma(奖状/表彰) vs report(成绩单)
|
||||
老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
### 老师场景
|
||||
1. 查看发布的任务 → `task publish-list --need-statistic -f json`
|
||||
2. 查看某班全部任务 → `task all-list --biz-id <classId>`
|
||||
3. 查看成绩单 → `report by-teacher --status 1`
|
||||
4. 查看班级成绩明细 → `report by-class --report-id <id> --class-id <classId>`
|
||||
5. 查看某班打卡完成情况 → `card user-statistic --card-id <cardId> --task-code <taskCode> --class-id <classId> --finish`
|
||||
6. 发布作业 → `homework create --identifier <orgId-staffId-UUID> --hw-content "第三章习题" --class-ids <classId>`
|
||||
7. 查看作业提交统计 → `homework submit-statistics --homework-id <id> --class-id <classId>`
|
||||
8. 批改作业写评语 → `homework create-comment --hw-content-detail-id <id> --comment "写得很好"`
|
||||
9. 删除作业 → `homework delete --homework-id <id> --yes`
|
||||
10. 发布通知 → `notice create --identifer <orgId-staffId-UUID> --content "明天放假" --class-ids <classId> --is-signed true`
|
||||
11. 查看通知签收情况 → `notice confirm-status --notice-id <id> --class-id <classId>`
|
||||
12. 删除通知 → `notice delete --notice-id <id> --yes`
|
||||
13. 颁发奖状 → `diploma create --identifier <orgId-staffId-UUID> --content "三好学生" --user-name <老师姓名> --class-ids <classId>`
|
||||
14. 查看奖状阅读统计 → `diploma statistics --diploma-id <id>`
|
||||
15. 删除奖状 → `diploma delete --diploma-id <id> --yes`
|
||||
|
||||
### 家长场景
|
||||
1. 查看孩子待办 → `task student-list --students '[{"userId":"<uid>","bizId":"<classId>"}]'`
|
||||
2. 确认通知 → `notice confirm --notice-id <id> --student-id <uid>`
|
||||
3. 查看孩子收到的通知 → `notice list-by-student --student-id <uid> --class-id <classId>`
|
||||
4. 查看孩子班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role guardian`
|
||||
5. 查看孩子进行中打卡 → `card list --status UNFINISH`
|
||||
6. 查看孩子作业列表 → `homework list-by-student --student-id <uid> --class-id <classId> --user-name <家长姓名>`
|
||||
7. 查看孩子收到的奖状 → `diploma list-by-student --student-id <uid> --class-id <classId>`
|
||||
|
||||
### 学生场景
|
||||
1. 查看自己的班级圈动态 → `circle posts --class-id <classId> --student-id <studentId> --target-role student`
|
||||
2. 提交作业 → `homework submit --hw-content-detail-id <id> --content "已完成"`
|
||||
3. 标记奖状已读 → `diploma read --diploma-id <id>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id |
|
||||
| `dws edu-contact class students` | `userId` | task student-list 的 students.userId |
|
||||
| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid |
|
||||
| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id |
|
||||
| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id |
|
||||
| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id |
|
||||
| `task publish-list` | `taskCode` | card user-statistic 的 --task-code |
|
||||
| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id |
|
||||
| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id |
|
||||
| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id |
|
||||
| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id |
|
||||
|
||||
---
|
||||
|
||||
## SKILL 摘要(原 dingtalk-edu-app/SKILL.md 正文)
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "查班级消息摘要" | `dws edu-app message summary-list --class-id <id> --cid <id> --target-role guardian\|student --status 0\|1` |
|
||||
| "查家校任务" | 见 [edu-app.md](./edu-app.md) `task` 章节 |
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 师生群本身 → 见本包 references/edu-group.md
|
||||
- 家校通讯录 → 见本包 references/edu-contact.md
|
||||
@@ -1,92 +0,0 @@
|
||||
# 家校通讯录 (edu-contact) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### school (学校/组织管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `school roles` | 查询用户在组织内的身份 | 无 |
|
||||
| `school structure` | 查询学校组织架构 | 无 |
|
||||
| `school periods` | 查询学校学段信息 | 无 |
|
||||
| `school type` | 查询学校组织类型 | 无 |
|
||||
| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) |
|
||||
| `school class-list` | 查询学校所有班级列表 | 无 |
|
||||
|
||||
### class (班级管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class detail` | 查询班级详情 | `--dept-id` |
|
||||
| `class students` | 查询班级学生信息 | `--dept-id` |
|
||||
| `class teachers` | 查询班级老师列表 | `--dept-id` |
|
||||
| `class same-name` | 查询班级内同名学生 | `--dept-id` |
|
||||
| `class user-role` | 查询用户在班级内的角色 | `--dept-id` |
|
||||
| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` |
|
||||
| `class headmaster` | 根据班级名查询班主任 | `--class-name` |
|
||||
| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` |
|
||||
| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` |
|
||||
| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` |
|
||||
| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` |
|
||||
| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` |
|
||||
| `class update-info` | 更新班级信息 | `--class-id` |
|
||||
| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` |
|
||||
| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` |
|
||||
| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` |
|
||||
| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` |
|
||||
| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` |
|
||||
| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` |
|
||||
|
||||
### family (家庭关系查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `family children` | 查询家长的孩子信息 | 无 |
|
||||
| `family parents` | 查询学生的家长信息 | 无 |
|
||||
|
||||
### teacher (教师管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `teacher classes` | 查询老师管理的班级列表 | 无 |
|
||||
| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"学校/组织/学段/组织架构" → school 子命令
|
||||
用户说"班级/学生/教师/班主任" → class 子命令
|
||||
用户说"家长/孩子/家庭关系" → family 子命令
|
||||
用户说"任教/科目" → teacher update-course
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 查询角色 → `school roles`
|
||||
2. 查看班级列表 → `school class-list`(提取 deptId)
|
||||
3. 查看班级详情 → `class detail --dept-id <deptId>`
|
||||
4. 查看学生列表 → `class students --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `school class-list` | `deptId` | class 子命令的 --dept-id |
|
||||
| `class students` | `userId` | update-student/delete-students 的 --student-user-id |
|
||||
| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id |
|
||||
|
||||
---
|
||||
|
||||
## SKILL 摘要(原 dingtalk-edu-contact/SKILL.md 正文)
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "我在学校的身份" | `dws edu-contact school roles` |
|
||||
| "学校组织架构" | `dws edu-contact school structure` |
|
||||
| "学校学段 / 类型" | `dws edu-contact school periods` / `school type` |
|
||||
| "学校所有班级" | `dws edu-contact school class-list` |
|
||||
| "学校统计" | `dws edu-contact school stats [--statistics-type <t>]` |
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 企业通讯录场景 → 切到 `dingtalk-contact`
|
||||
@@ -1,68 +0,0 @@
|
||||
# 家庭群 (edu-familygroup) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### group (家庭群查询)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` |
|
||||
| `group list-children` | 查询家长绑定的孩子列表 | `--uid` |
|
||||
|
||||
### manage (家庭群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `manage create` | 创建家庭群 | `--uid`, `--children` |
|
||||
| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` |
|
||||
| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) |
|
||||
| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"家庭群存在/有没有家庭群" → group check-exists
|
||||
用户说"孩子列表/我的孩子" → group list-children
|
||||
用户说"创建家庭/建群" → manage create
|
||||
用户说"邀请家长/拉家长入群" → manage invite-parent
|
||||
用户说"添加孩子/加娃" → manage add-child
|
||||
用户说"应用权限/小天地/学习视频" → manage toggle-app
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查家庭群是否存在 → `group check-exists --uid <uid> --group-name <name>`
|
||||
2. 如不存在,创建家庭群 → `manage create --uid <uid> --children '<json>'`
|
||||
3. 查看已绑定孩子 → `group list-children --uid <uid>`
|
||||
4. 邀请其他家长 → `manage invite-parent --org-id <orgId> --uid <uid> --mobile <phone>`
|
||||
5. 添加孩子 → `manage add-child --org-id <orgId> --uid <uid> --name <name> --mobile <phone>`
|
||||
6. 管理应用权限 → `manage toggle-app --org-id <orgId> --uid <uid> --child-staff-id <id> --app-type XIAOTIANDI --open true`
|
||||
|
||||
## 参数说明
|
||||
|
||||
### manage create --children 格式
|
||||
|
||||
```json
|
||||
[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]
|
||||
```
|
||||
|
||||
每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。
|
||||
|
||||
### manage add-child --students 格式
|
||||
|
||||
```json
|
||||
[{"schoolOrgId":111,"studentStaffId":"stu001"}]
|
||||
```
|
||||
|
||||
每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。
|
||||
|
||||
### manage toggle-app --app-type 可选值
|
||||
|
||||
- `XIAOTIANDI`:小天地(学生圈)
|
||||
- `LEARNING_VIDEO`:学习视频
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id |
|
||||
| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id |
|
||||
| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid |
|
||||
@@ -1,77 +0,0 @@
|
||||
# 家校群 (edu-group) 命令参考
|
||||
|
||||
## 命令总览
|
||||
|
||||
### student-group (师生群管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `student-group info` | 查询班级师生群信息 | `--dept-id` |
|
||||
| `student-group exists` | 检查是否已创建师生群 | `--dept-id` |
|
||||
| `student-group members` | 查询师生群成员列表 | `--dept-id` |
|
||||
| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` |
|
||||
| `student-group conversation` | 查询班级群会话详情 | `--dept-id` |
|
||||
| `student-group create` | 创建班级师生群 | `--dept-id` |
|
||||
| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` |
|
||||
|
||||
### class-group (班级群会话管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` |
|
||||
| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` |
|
||||
| `class-group exists` | 检查班级群是否存在 | `--dept-id` |
|
||||
| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` |
|
||||
|
||||
### batch (批量操作)
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` |
|
||||
| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` |
|
||||
| `batch create-student-groups` | 批量创建师生群 | 无 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"师生群/学生群" → student-group 子命令
|
||||
用户说"班级群/群会话" → class-group 子命令
|
||||
用户说"批量/一键操作" → batch 子命令
|
||||
|
||||
关键区分: student-group(师生群) vs class-group(班级群会话管理)
|
||||
|
||||
## 核心工作流
|
||||
|
||||
1. 检查群是否存在 → `student-group exists --dept-id <deptId>`
|
||||
2. 如不存在,创建 → `student-group create --dept-id <deptId>`
|
||||
3. 查看成员 → `student-group members --dept-id <deptId>`
|
||||
4. 获取会话ID → `class-group conversation-id --dept-id <deptId>`
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id |
|
||||
| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid |
|
||||
| `batch check-student-group` | 未创建群的班级 | batch create-student-groups |
|
||||
|
||||
---
|
||||
|
||||
## SKILL 摘要(原 dingtalk-edu-group/SKILL.md 正文)
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "查师生群信息 / 是否已建" | `dws edu-group student-group info --dept-id <id>` / `exists --dept-id <id>` |
|
||||
| "师生群成员" | `dws edu-group student-group members --dept-id <id>` |
|
||||
| "建班级师生群" | `dws edu-group student-group create --dept-id <id>` |
|
||||
| "解散班级师生群 ⚠️" | `dws edu-group student-group disband --dept-id <id>`(需用户确认 `--yes`) |
|
||||
|
||||
## 危险操作
|
||||
|
||||
`student-group disband` 不可逆,必须先向用户确认再加 `--yes`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 班级列表 → 见本包 references/edu-contact.md(school class-list)
|
||||
- 企业群 → 切到 `dingtalk-chat`
|
||||
@@ -24,7 +24,7 @@
|
||||
- `dws report outbox list` = 列出**我发出**的日报(我创建或提交的)。
|
||||
- `dws report entry get --report-id <reportId> --format json` = 读取单份日报正文 + 钉钉跳转链接。
|
||||
- `dws report entry stats --report-id <reportId> --format json` = 读取单份日报的已读统计。
|
||||
- `dws report entry submit --template-id ... --contents-file ...` = 按模版提交一份新日报。
|
||||
- `dws report entry submit --template-id ... --contents-file ... --to-user-ids ...` = 按模版提交一份新日报(--to-user-ids 必填:无接收人的日志对任何人都不可见)。
|
||||
- `dws report template list` = 列出可用日报模版。
|
||||
- `dws report template get --name "<模版名>"` = 读取单个模版的字段定义(contents 拼装来源)。
|
||||
|
||||
@@ -153,7 +153,7 @@ CLI 列表命令只返回 JSON-first 数据,不把 Markdown 表作为裸文本
|
||||
|
||||
1. `dws report template list --format json` — 取 `report_template_id` 与可见模版名
|
||||
2. `dws report template get --name "<模版名>" --format json` — 取 `result.report_template_fields[]`,每项含 `field_name` / `field_sort` / `field_type`
|
||||
3. `dws report entry submit --template-id <id> --contents-file <tmp.json> --format json` — contents 数组按上面「字段映射」严格对齐第 2 步:`field_name → key`,`field_sort → sort`,`field_type → type`,再填 `content` 与 `contentType`;CLI 提交成功后会自动反查详情并追加钉钉打开链接字段,返回中直接取 `reportId` 与 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl`
|
||||
3. `dws report entry submit --template-id <id> --contents-file <tmp.json> --to-user-ids <userId1>,<userId2> --format json` — contents 数组按上面「字段映射」严格对齐第 2 步:`field_name → key`,`field_sort → sort`,`field_type → type`,再填 `content` 与 `contentType`;`--to-user-ids` 必填:无接收人的提交服务端仍返回成功但日志对任何人都不可见;CLI 提交成功后会自动反查详情并追加钉钉打开链接字段,返回中直接取 `reportId` 与 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl`
|
||||
4. 仅当第 3 步返回中缺少 `dingtalkOpenUrl` 时,执行 `dws report entry get --report-id <reportId> --format json` 补取 `result.url`(`dingtalk://...` 协议深链接)。final reply 中优先使用 `dingtalkOpenMarkdownLink`,否则用 `[在钉钉中查看日志](dingtalkOpenUrl)`。**禁止把 raw `dingtalk://...` URL 原样写进回复**,必须包成 markdown link 让用户可点击跳转钉钉客户端
|
||||
|
||||
跳步风险(已实证):
|
||||
@@ -162,6 +162,7 @@ CLI 列表命令只返回 JSON-first 数据,不把 Markdown 表作为裸文本
|
||||
- 跳过第 2 步用 LLM 经验编 `key` 名 → 服务端返回 `PARAM_ERROR`,且**不告诉你哪个字段错**;服务端 PARAM_ERROR 信号弱,事后无法定位,**只能靠前置 schema 同步避免**;
|
||||
- 未取到 `dingtalkOpenUrl` 且不补查 `entry get` → 用户拿不到跳转链接,无法在钉钉客户端打开刚提交的日志查看 / 修改;
|
||||
- 用 `--contents` 直传长 JSON → shell 引号转义破坏 JSON → `INPUT_INVALID_JSON`。**长内容务必走 `--contents-file <path>` 或 `--contents -` (stdin)**。
|
||||
- 不传 `--to-user-ids` → 服务端仍返回成功但日志对任何接收人都不可见;CLI 已强制必填,缺 flag 或传空值都会被拒绝
|
||||
- contents JSON 大小限制为 10MB,**不支持分批次提交**。超过限制需精简内容或拆分为多个独立日志提交。
|
||||
|
||||
推荐:Agent 在多轮场景中应在内存里持久化第 1/2 步的结果,避免每轮重新跑。
|
||||
@@ -193,22 +194,22 @@ Usage:
|
||||
dws report entry submit [flags]
|
||||
Example:
|
||||
# 推荐:长内容走文件,避免 shell 引号问题
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --format json
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --to-user-ids <userId1>,<userId2> --format json
|
||||
|
||||
# stdin 输入
|
||||
cat report.json | dws report entry submit --template-id <templateId> --contents - --format json
|
||||
cat report.json | dws report entry submit --template-id <templateId> --contents - --to-user-ids <userId1> --format json
|
||||
|
||||
# 内联(短内容)
|
||||
dws report entry submit --template-id <templateId> \
|
||||
--contents '[{"key":"今日完成","sort":"0","content":"完成了需求评审","contentType":"markdown","type":"1"}]' \
|
||||
--format json
|
||||
--to-user-ids <userId1> --format json
|
||||
Flags:
|
||||
--template-id string 日志模版 ID (必填),从 template list 返回中取
|
||||
--contents string 日志内容 JSON 数组 (必填,或用 --contents-file);传 `-` 表示从 stdin 读取
|
||||
--contents-file string 从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容)
|
||||
--dd-from string 创建来源标识 (默认 dws)
|
||||
--to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true)
|
||||
--to-user-ids string 接收人 userId,逗号分隔 (可选)
|
||||
--to-user-ids string 接收人 userId,逗号分隔 (必填);无接收人的日志提交后对任何人都不可见
|
||||
```
|
||||
|
||||
|
||||
@@ -342,8 +343,8 @@ dws report template list --format json
|
||||
# 2. 按名称读取模版字段定义
|
||||
dws report template get --name "日报" --format json
|
||||
|
||||
# 2b. 提交日志(从步骤 1/2 取 templateId 与 contents 字段)— 推荐 --contents-file 传入避免 shell 引号
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --format json
|
||||
# 2b. 提交日志(从步骤 1/2 取 templateId 与 contents 字段)— 推荐 --contents-file 传入避免 shell 引号;--to-user-ids 必填
|
||||
dws report entry submit --template-id <templateId> --contents-file ./report.json --to-user-ids <userId1>,<userId2> --format json
|
||||
# submit 成功会自动反查详情并追加 dingtalkOpenMarkdownLink / dingtalkOpenUrl;
|
||||
# final reply 直接使用 dingtalkOpenMarkdownLink: [在钉钉中查看日志](dingtalk://...)
|
||||
|
||||
@@ -461,7 +462,7 @@ dws report outbox list --cursor 0 --size 20 --format json
|
||||
|--------|------|
|
||||
| "今天 / 最近 / 最近一周收到的日志" | `dws report inbox list --start "<ISO+08>" --end "<ISO+08>" --cursor 0 --size 20 --format json` |
|
||||
| "看日志模版" | `dws report template list --format json` → `dws report template get --name "<模版名>" --format json` |
|
||||
| "提交日报 / 周报(按模版)" | `dws report entry submit --template-id <id> --contents-file <tmp.json> --format json` |
|
||||
| "提交日报 / 周报(按模版)" | `dws report entry submit --template-id <id> --contents-file <tmp.json> --to-user-ids <userId1>,<userId2> --format json` |
|
||||
| "我已发送 / 我创建 / 我发过的日志" | `dws report outbox list --cursor 0 --size 20 --format json` |
|
||||
| "看日志正文 / 总结多篇日志" | 列表取 `reportId` 后逐篇 `dws report entry get --report-id <id> --format json` |
|
||||
| "日志已读统计" | `dws report entry stats --report-id <id> --format json` |
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
- 无下载权限 — 文档分享设置不允许 → 报告用户,建议联系文档所有者
|
||||
- `update --mode overwrite` 意外清空 — overwrite 会清空原内容后重写 → 默认用 `--mode append`,overwrite 前必须跟用户确认
|
||||
- 块编辑 blockId 无效 — blockId 过期或文档结构已变 → 先 `block list` 刷新获取最新 blockId
|
||||
- `CONTENT_TRUNCATED` — 分片写入持续超时,分片大小已减半至最小阈值(5000 字符)仍无法成功 → 后端服务可能过载或网络异常。已写入部分内容可通过 `doc read --node <ID>` 查看,待后端恢复后从断点处用 `doc update --mode append` 继续追加
|
||||
- `doc_write_commit_unknown` — 某个分片写入超时,服务端是否已提交无法判断 → 不会自动重试(重放会重复追加)。`details` 给出 `chunksWritten` / `chunksTotal` / `failedStage`;先用 `doc read --node <ID>` 回读确认实际写到哪一片,只有确认未提交才重新执行,再从断点处用 `doc update --mode append` 继续追加
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -28,12 +27,14 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
)
|
||||
|
||||
const (
|
||||
mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
|
||||
mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE"
|
||||
mockCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
mockMCPSmokeHelperEnv = "DWS_MOCK_MCP_SMOKE_HELPER"
|
||||
mockMCPSmokeCAEnv = "DWS_MOCK_MCP_SMOKE_CA_FILE"
|
||||
mockMCPSmokeDownloadDialEnv = "DWS_MOCK_MCP_SMOKE_DOWNLOAD_DIAL"
|
||||
mockCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
)
|
||||
|
||||
type recordedToolCall struct {
|
||||
@@ -67,6 +68,9 @@ func TestCLIHelperProcess(t *testing.T) {
|
||||
_ = os.Setenv("GODEBUG", "x509usefallbackroots=1")
|
||||
x509.SetFallbackRoots(pool)
|
||||
}
|
||||
if dialAddr := strings.TrimSpace(os.Getenv(mockMCPSmokeDownloadDialEnv)); dialAddr != "" {
|
||||
localio.SetSecureDownloadDialTargetForTest(dialAddr)
|
||||
}
|
||||
|
||||
marker := -1
|
||||
for i, arg := range os.Args {
|
||||
@@ -267,15 +271,14 @@ func TestMultiIME2E_NaturalTargetsCompletenessAndWriteBoundaries(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
env := isolatedCLIEnv(t, map[string]string{
|
||||
"DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact",
|
||||
"DINGTALK_CHAT_MCP_URL": server.URL + "/mcp/chat",
|
||||
"DINGTALK_IM_MCP_URL": server.URL + "/mcp/im",
|
||||
"HTTPS_PROXY": download.ProxyURL,
|
||||
"https_proxy": download.ProxyURL,
|
||||
"SSL_CERT_FILE": download.CAFile,
|
||||
mockMCPSmokeCAEnv: download.CAFile,
|
||||
"DWS_CONFIG_DIR": authConfigDir,
|
||||
"DWS_KEYCHAIN_DIR": authKeychainDir,
|
||||
"DINGTALK_CONTACT_MCP_URL": server.URL + "/mcp/contact",
|
||||
"DINGTALK_CHAT_MCP_URL": server.URL + "/mcp/chat",
|
||||
"DINGTALK_IM_MCP_URL": server.URL + "/mcp/im",
|
||||
mockMCPSmokeDownloadDialEnv: download.DialAddr,
|
||||
"SSL_CERT_FILE": download.CAFile,
|
||||
mockMCPSmokeCAEnv: download.CAFile,
|
||||
"DWS_CONFIG_DIR": authConfigDir,
|
||||
"DWS_KEYCHAIN_DIR": authKeychainDir,
|
||||
})
|
||||
reset := func() {
|
||||
requestsMu.Lock()
|
||||
@@ -584,7 +587,7 @@ func recordedToolNames(calls []recordedToolCall) []string {
|
||||
|
||||
type trustedDownloadFixture struct {
|
||||
URL string
|
||||
ProxyURL string
|
||||
DialAddr string
|
||||
CAFile string
|
||||
}
|
||||
|
||||
@@ -645,38 +648,6 @@ func newTrustedDownloadFixture(t *testing.T, body []byte) trustedDownloadFixture
|
||||
downloadServer.StartTLS()
|
||||
t.Cleanup(downloadServer.Close)
|
||||
|
||||
proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodConnect {
|
||||
http.Error(w, "CONNECT required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
upstream, err := net.DialTimeout("tcp", downloadServer.Listener.Addr().String(), 2*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
_ = upstream.Close()
|
||||
http.Error(w, "hijacking unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
client, buffered, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
_ = upstream.Close()
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(buffered, "HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
_ = buffered.Flush()
|
||||
go func() {
|
||||
_, _ = io.Copy(upstream, client)
|
||||
_ = upstream.Close()
|
||||
}()
|
||||
_, _ = io.Copy(client, upstream)
|
||||
_ = client.Close()
|
||||
}))
|
||||
t.Cleanup(proxyServer.Close)
|
||||
|
||||
caFile := filepath.Join(t.TempDir(), "download-ca.pem")
|
||||
caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})
|
||||
if err := os.WriteFile(caFile, caPEM, 0o600); err != nil {
|
||||
@@ -684,7 +655,7 @@ func newTrustedDownloadFixture(t *testing.T, body []byte) trustedDownloadFixture
|
||||
}
|
||||
return trustedDownloadFixture{
|
||||
URL: "https://download.dingtalk.com/artifact.bin",
|
||||
ProxyURL: proxyServer.URL,
|
||||
DialAddr: downloadServer.Listener.Addr().String(),
|
||||
CAFile: caFile,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const stableMergedPRIdentityHelper = `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
|
||||
);
|
||||
}`
|
||||
|
||||
func TestCoverageBaselineRepairUsesStableMergedPRIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify merged-PR repair identity semantics")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
workflowPath := filepath.Join(
|
||||
root,
|
||||
".github",
|
||||
"workflows",
|
||||
"coverage-baseline-repair.yml",
|
||||
)
|
||||
data, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", workflowPath, err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
var parsedWorkflow any
|
||||
if err := yaml.Unmarshal(data, &parsedWorkflow); err != nil {
|
||||
t.Fatalf("parse %s: %v", workflowPath, err)
|
||||
}
|
||||
var scripts []string
|
||||
collectGitHubScripts(parsedWorkflow, &scripts)
|
||||
helperCount := 0
|
||||
checkCount := 0
|
||||
for _, script := range scripts {
|
||||
helperCount += strings.Count(script, stableMergedPRIdentityHelper)
|
||||
checkCount += strings.Count(script, "!isStableMergedPRIdentity(")
|
||||
}
|
||||
if helperCount != 2 {
|
||||
t.Fatalf("stable merged-PR identity helper occurrences = %d, want dispatcher and producer", helperCount)
|
||||
}
|
||||
if checkCount != 2 {
|
||||
t.Fatalf("stable merged-PR identity checks = %d, want dispatcher and producer", checkCount)
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"currentPull.base.sha",
|
||||
"const baseSha =",
|
||||
"base_sha",
|
||||
} {
|
||||
if strings.Contains(workflow, forbidden) {
|
||||
t.Errorf("merged-PR repair must not bind mutable Ref SHA via %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"const baseRef = eventPull?.base?.ref;",
|
||||
"baseRef !== 'main'",
|
||||
"const headSha = eventPull?.head?.sha;",
|
||||
"const headSha = payload.head_sha;",
|
||||
"head_sha: headSha",
|
||||
"currentPull.head?.sha === headSha",
|
||||
"currentPull.merge_commit_sha === mergeCommitSha",
|
||||
"await requireMainContainment(mergeCommitSha)",
|
||||
"await requireMainContainment(targetSha)",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("merged-PR repair missing stable identity guard %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
verification := stableMergedPRIdentityHelper + `
|
||||
const pullNumber = 1077;
|
||||
const headSha = '1'.repeat(40);
|
||||
const mergeCommitSha = '2'.repeat(40);
|
||||
const validPull = {
|
||||
number: pullNumber,
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
merged_at: '2026-08-25T04:55:30Z',
|
||||
base: {ref: 'main', sha: '3'.repeat(40)},
|
||||
head: {sha: headSha},
|
||||
merge_commit_sha: mergeCommitSha,
|
||||
};
|
||||
const cases = [
|
||||
['base ref advanced after event', {...validPull, base: {...validPull.base, sha: '4'.repeat(40)}}, true],
|
||||
['base sha omitted by projection', {...validPull, base: {ref: 'main'}}, true],
|
||||
['wrong pull number', {...validPull, number: pullNumber + 1}, false],
|
||||
['open pull request', {...validPull, state: 'open'}, false],
|
||||
['not merged', {...validPull, merged: false}, false],
|
||||
['missing merged at', {...validPull, merged_at: null}, false],
|
||||
['empty merged at', {...validPull, merged_at: ''}, false],
|
||||
['non-string merged at', {...validPull, merged_at: 1}, false],
|
||||
['wrong base ref', {...validPull, base: {...validPull.base, ref: 'release'}}, false],
|
||||
['missing base ref', {...validPull, base: {}}, false],
|
||||
['missing head', {...validPull, head: null}, false],
|
||||
['wrong head sha', {...validPull, head: {sha: '5'.repeat(40)}}, false],
|
||||
['wrong merge sha', {...validPull, merge_commit_sha: '6'.repeat(40)}, false],
|
||||
['null pull', null, false],
|
||||
];
|
||||
for (const [name, currentPull, want] of cases) {
|
||||
const got = isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("stable merged-PR identity verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -135,15 +135,32 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
t.Fatalf("ReadFile(ci.yml) error = %v", err)
|
||||
}
|
||||
admission := string(data)
|
||||
for _, want := range []string{
|
||||
"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",
|
||||
} {
|
||||
if !strings.Contains(admission, want) {
|
||||
t.Errorf("CI workflow missing latest-PR/exact-main producer contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"format('pr-{0}-{1}-{2}'",
|
||||
"github.event.pull_request.number || github.ref",
|
||||
} {
|
||||
if strings.Contains(admission, forbidden) {
|
||||
t.Errorf("CI workflow retains a stale concurrency identity %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
currentStart := strings.Index(admission, "\n coverage-current:\n")
|
||||
fullStart := strings.Index(admission, "\n coverage-current-full:\n")
|
||||
supportingStart := strings.Index(admission, "\n coverage-supporting:\n")
|
||||
baselineStart := strings.Index(admission, "\n coverage-baseline:\n")
|
||||
metadataStart := strings.Index(admission, "\n coverage-main-metadata:\n")
|
||||
gateStart := strings.Index(admission, "\n coverage:\n")
|
||||
policyStart := strings.Index(admission, "\n policy:\n")
|
||||
if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart ||
|
||||
baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart {
|
||||
baselineStart <= supportingStart || metadataStart <= baselineStart || gateStart <= metadataStart || policyStart <= gateStart {
|
||||
t.Fatal("CI workflow missing ordered coverage job boundaries")
|
||||
}
|
||||
|
||||
@@ -175,7 +192,10 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
baselineJob := admission[baselineStart:gateStart]
|
||||
baselineJob := admission[baselineStart:metadataStart]
|
||||
if !strings.Contains(baselineJob, "timeout-minutes: 30") {
|
||||
t.Error("coverage-baseline must retain enough headroom for an authoritative cold-cache fallback")
|
||||
}
|
||||
cachePath := "coverage-cache.txt"
|
||||
baselineKey := "dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}"
|
||||
for _, want := range []string{
|
||||
@@ -201,8 +221,50 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
t.Error("coverage baseline cache must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline")
|
||||
}
|
||||
|
||||
metadataJob := admission[metadataStart:gateStart]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'push'",
|
||||
"needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true'",
|
||||
"timeout-minutes: 30",
|
||||
"fetch-depth: 0",
|
||||
"PUSH_BEFORE_SHA: ${{ github.event.before }}",
|
||||
"PUSH_AFTER_SHA: ${{ github.event.after }}",
|
||||
"git merge-base --is-ancestor \"$PUSH_BEFORE_SHA\" \"$PUSH_AFTER_SHA\"",
|
||||
"git diff --name-only --no-renames -z",
|
||||
`^\.changes/[a-z0-9][a-z0-9._-]*\.md$`,
|
||||
`^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$`,
|
||||
"Refusing coverage-cache promotion for unreviewed change-fragment path",
|
||||
"Refusing coverage-cache promotion for executable path",
|
||||
"COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA",
|
||||
"id: metadata-current-cache",
|
||||
"id: metadata-source-cache",
|
||||
"key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}",
|
||||
"go test -count=1 -p 1",
|
||||
"./ ./cmd/... ./internal/... ./skills/...",
|
||||
"key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}",
|
||||
"id: metadata-target-cache-verification",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
} {
|
||||
if !strings.Contains(metadataJob, want) {
|
||||
t.Errorf("metadata-only main cache producer missing contract %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(metadataJob, "restore-keys") {
|
||||
t.Error("metadata-only main cache promotion must use exact source and target keys")
|
||||
}
|
||||
if strings.Count(metadataJob, "path: "+cachePath) != 4 {
|
||||
t.Error("metadata-only producer must restore target/source, save, and verify through the shared cache version path")
|
||||
}
|
||||
|
||||
gateJob := admission[gateStart:policyStart]
|
||||
for _, want := range []string{
|
||||
"- coverage-main-metadata",
|
||||
"MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}",
|
||||
"main_metadata_expected=success",
|
||||
`"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"`,
|
||||
"pattern: coverage-current-*",
|
||||
"merge-multiple: true",
|
||||
"for shard in app cli generators helpers remaining; do",
|
||||
@@ -212,6 +274,12 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
"cp coverage.txt coverage-cache.txt",
|
||||
"path: " + cachePath,
|
||||
"key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"name: Verify push coverage cache exists",
|
||||
"id: push-cache-verification",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
`"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`,
|
||||
} {
|
||||
if !strings.Contains(gateJob, want) {
|
||||
@@ -219,7 +287,456 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Count(gateJob, "path: "+cachePath) != 1 {
|
||||
t.Error("green main push must save the candidate profile through the same cache path/version as baseline restore")
|
||||
if strings.Count(gateJob, "path: "+cachePath) != 2 {
|
||||
t.Error("green main push must save and verify the candidate profile through the same cache path/version as baseline restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormulaCoverageBaselinePromotionContract(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
promotionData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-promotion.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(coverage-baseline-promotion.yml) error = %v", err)
|
||||
}
|
||||
promotion := string(promotionData)
|
||||
for _, want := range []string{
|
||||
"repository_dispatch:",
|
||||
"types: [coverage-baseline-promote]",
|
||||
"checks: write",
|
||||
"contents: read",
|
||||
"group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}",
|
||||
"cancel-in-progress: false",
|
||||
"queue: max",
|
||||
"github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'",
|
||||
"timeout-minutes: 30",
|
||||
"context.payload.client_payload?.target_sha",
|
||||
"context.payload.client_payload?.source_run_id",
|
||||
"context.payload.client_payload?.check_run_id",
|
||||
"targetCommit.parents.length !== 1",
|
||||
"targetCommit.author?.login !== 'github-actions[bot]'",
|
||||
"targetCommit.committer?.login !== 'github-actions[bot]'",
|
||||
"files.length !== 1",
|
||||
"Formula/dingtalk-workspace-cli.rb",
|
||||
"Formula/dingtalk-workspace-cli-beta.rb",
|
||||
"[skip ci",
|
||||
"await requireSuccessfulAdmission(parentSha, 'Formula parent')",
|
||||
"await requireSuccessfulAdmission(targetSha, 'Formula target')",
|
||||
"for (let attempt = 1; attempt <= 6; attempt += 1)",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"run.app?.slug !== 'github-actions'",
|
||||
"run.conclusion !== 'success'",
|
||||
"basehead: `${targetSha}...${branch.commit.sha}`",
|
||||
"['ahead', 'identical'].includes(containment.status)",
|
||||
"promotionCheck.name !== 'Coverage Baseline Cache'",
|
||||
"promotionCheck.external_id !== promotionExternalId",
|
||||
"promotionCheck.status !== 'queued'",
|
||||
"core.setOutput('check_run_id', String(checkRunId))",
|
||||
"name: Mark Formula cache promotion in progress",
|
||||
"status: 'in_progress'",
|
||||
"persist-credentials: false",
|
||||
"ref: ${{ steps.validate-target.outputs.target_sha }}",
|
||||
"test \"$(git rev-parse HEAD^)\" = \"$PARENT_SHA\"",
|
||||
"id: target-cache",
|
||||
"id: parent-cache",
|
||||
"key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"go test -count=1 -p 1",
|
||||
"./ ./cmd/... ./internal/... ./skills/...",
|
||||
"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",
|
||||
"id: formula-target-cache-verification",
|
||||
"EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
"name: Complete Formula cache promotion acknowledgement",
|
||||
"PROMOTION_JOB_STATUS: ${{ job.status }}",
|
||||
"conclusion: succeeded ? 'success' : 'failure'",
|
||||
} {
|
||||
if !strings.Contains(promotion, want) {
|
||||
t.Errorf("Formula baseline promotion missing contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
|
||||
"actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff",
|
||||
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
} {
|
||||
if !strings.Contains(promotion, want) {
|
||||
t.Errorf("Formula baseline promotion must pin trusted action %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"pull_request:",
|
||||
"pull_request_target:",
|
||||
"push:",
|
||||
"workflow_dispatch:",
|
||||
"restore-keys",
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"RELEASE_GOVERNANCE_TOKEN",
|
||||
"contents: write",
|
||||
} {
|
||||
if strings.Contains(promotion, forbidden) {
|
||||
t.Errorf("Formula baseline promotion must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
validateIndex := strings.Index(promotion, "name: Validate Formula-only main target")
|
||||
checkoutIndex := strings.Index(promotion, "name: Check out validated Formula-only target")
|
||||
if validateIndex < 0 || checkoutIndex <= validateIndex {
|
||||
t.Error("Formula target must be fully validated before it is checked out or executed")
|
||||
}
|
||||
ackReadIndex := strings.Index(promotion, "const {data: promotionCheck} = await github.rest.checks.get")
|
||||
ackOutputIndex := strings.Index(promotion, "core.setOutput('check_run_id', String(checkRunId))")
|
||||
targetReadIndex := strings.Index(promotion, "const {data: targetCommit} = await github.rest.repos.getCommit")
|
||||
if ackReadIndex < 0 || ackOutputIndex <= ackReadIndex || targetReadIndex <= ackOutputIndex {
|
||||
t.Error("Formula acknowledgement must be identity-checked and bound to the finalizer before target validation")
|
||||
}
|
||||
if strings.Count(promotion, "path: coverage-cache.txt") != 4 {
|
||||
t.Error("Formula promotion must restore target/source, save, and verify through the shared cache version path")
|
||||
}
|
||||
|
||||
releaseData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(release.yml) error = %v", err)
|
||||
}
|
||||
release := string(releaseData)
|
||||
publishStart := strings.Index(release, " publish-release:\n")
|
||||
sealStart := strings.Index(release, " - name: Seal Formula-only Code Admission contexts\n")
|
||||
if publishStart < 0 || sealStart <= publishStart {
|
||||
t.Fatal("release workflow missing Formula-only seal start")
|
||||
}
|
||||
if !strings.Contains(release[publishStart:sealStart], "timeout-minutes: 30") {
|
||||
t.Error("publish-release must not be extended by cache acknowledgement polling")
|
||||
}
|
||||
sealEnd := strings.Index(release[sealStart:], "\n - name: Reverify exact immutable npm package\n")
|
||||
if sealEnd < 0 {
|
||||
t.Fatal("release workflow missing Formula-only seal boundaries")
|
||||
}
|
||||
seal := release[sealStart : sealStart+sealEnd]
|
||||
for _, want := range []string{
|
||||
`core.setOutput("coverage_baseline_required", "true")`,
|
||||
`core.setOutput("coverage_baseline_commit", commit)`,
|
||||
} {
|
||||
if !strings.Contains(seal, want) {
|
||||
t.Errorf("Formula seal missing cache-promotion dispatch contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
||||
"github.rest.checks.get",
|
||||
"promotionComplete",
|
||||
"Coverage Baseline Cache",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("irreversible publish-release must not wait on cache through %q", forbidden)
|
||||
}
|
||||
}
|
||||
confirmationStart := strings.Index(release, " coverage-baseline-confirmation:\n")
|
||||
deliveryGateStart := strings.Index(release, " release-delivery-gate:\n")
|
||||
if confirmationStart < 0 || deliveryGateStart <= confirmationStart {
|
||||
t.Fatal("release workflow missing independent Formula baseline confirmation job")
|
||||
}
|
||||
confirmation := release[confirmationStart:deliveryGateStart]
|
||||
for _, want := range []string{
|
||||
"if (!['true', 'false'].includes(rawRequired))",
|
||||
"github.rest.checks.create",
|
||||
"name: 'Coverage Baseline Cache'",
|
||||
"status: 'queued'",
|
||||
"external_id: expectedExternalId",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-promote'",
|
||||
"check_run_id: String(promotionCheck.id)",
|
||||
"conclusion: 'failure'",
|
||||
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
||||
"github.rest.checks.get",
|
||||
"currentCheck.name !== 'Coverage Baseline Cache'",
|
||||
"currentCheck.conclusion !== 'success'",
|
||||
"setTimeout(resolve, 10000)",
|
||||
"Formula baseline promotion timed out",
|
||||
} {
|
||||
if !strings.Contains(confirmation, want) {
|
||||
t.Errorf("independent Formula baseline confirmation missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverageBaselineRepairContract(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-repair.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(coverage-baseline-repair.yml) error = %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
for _, want := range []string{
|
||||
"pull_request_target:",
|
||||
"branches: [main]",
|
||||
"types: [closed]",
|
||||
"workflow_run:",
|
||||
"workflows: [CI]",
|
||||
"types: [completed]",
|
||||
"repository_dispatch:",
|
||||
"types: [coverage-baseline-repair]",
|
||||
"schedule:",
|
||||
`cron: "23 * * * *"`,
|
||||
"workflow_dispatch:",
|
||||
"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",
|
||||
"queue: max",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
t.Errorf("coverage baseline repair missing workflow contract %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
dispatchStart := strings.Index(workflow, "\n dispatch-merged-pr:\n")
|
||||
failedDispatchStart := strings.Index(workflow, "\n dispatch-failed-ci:\n")
|
||||
repairStart := strings.Index(workflow, "\n repair:\n")
|
||||
if dispatchStart < 0 || failedDispatchStart <= dispatchStart || repairStart <= failedDispatchStart {
|
||||
t.Fatal("coverage baseline repair is missing ordered merged-PR, failed-CI, and producer jobs")
|
||||
}
|
||||
dispatcher := workflow[dispatchStart:failedDispatchStart]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'pull_request_target'",
|
||||
"github.event.pull_request.merged == true",
|
||||
"github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'",
|
||||
"actions: read",
|
||||
"contents: write",
|
||||
"pull-requests: read",
|
||||
"actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b",
|
||||
"context.payload.repository?.default_branch !== 'main'",
|
||||
"currentPull.state === 'closed'",
|
||||
"currentPull.merged === true",
|
||||
"typeof currentPull.merged_at === 'string'",
|
||||
"const baseRef = eventPull?.base?.ref;",
|
||||
"baseRef !== 'main'",
|
||||
"currentPull.base?.ref === 'main'",
|
||||
"isStableMergedPRIdentity(",
|
||||
"currentPull.head?.sha === headSha",
|
||||
"currentPull.merge_commit_sha === mergeCommitSha",
|
||||
"for (let attempt = 1; attempt <= 6; attempt += 1)",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"basehead: `${targetSha}...${branch.commit.sha}`",
|
||||
"['ahead', 'identical'].includes(comparison.status)",
|
||||
"for (let attempt = 1; attempt <= 12; attempt += 1)",
|
||||
"github.rest.actions.getWorkflow",
|
||||
"workflow_id: '.github/workflows/ci.yml'",
|
||||
"ciWorkflow.name !== 'CI'",
|
||||
"ciWorkflow.path !== '.github/workflows/ci.yml'",
|
||||
"ciWorkflow.state !== 'active'",
|
||||
"github.rest.actions.listWorkflowRunsForRepo",
|
||||
"branch: 'main'",
|
||||
"event: 'push'",
|
||||
"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)",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"repair dispatch is unnecessary",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-repair'",
|
||||
"source: 'merged_pr'",
|
||||
"pull_number: String(pullNumber)",
|
||||
"head_sha: headSha",
|
||||
"merge_commit_sha: mergeCommitSha",
|
||||
} {
|
||||
if !strings.Contains(dispatcher, want) {
|
||||
t.Errorf("merged-PR repair dispatcher missing %q", want)
|
||||
}
|
||||
}
|
||||
ciWorkflowLookup := strings.Index(dispatcher, "github.rest.actions.getWorkflow")
|
||||
pushRunLookup := strings.Index(dispatcher, "github.rest.actions.listWorkflowRunsForRepo")
|
||||
repairDispatch := strings.Index(dispatcher, "github.rest.repos.createDispatchEvent")
|
||||
if ciWorkflowLookup < 0 || pushRunLookup <= ciWorkflowLookup || repairDispatch <= pushRunLookup {
|
||||
t.Error("merged-PR dispatcher must bind the fixed CI workflow before exhausting exact push-run lookup and dispatch")
|
||||
}
|
||||
stableIdentityCheck := strings.Index(dispatcher, "if (!isStableMergedPRIdentity(")
|
||||
mainContainmentCheck := strings.Index(dispatcher, "await requireMainContainment(mergeCommitSha)")
|
||||
if stableIdentityCheck < 0 || mainContainmentCheck <= stableIdentityCheck || repairDispatch <= mainContainmentCheck {
|
||||
t.Error("merged-PR dispatcher must prove stable PR identity and main containment before dispatch")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout@",
|
||||
"actions/cache/",
|
||||
"actions/setup-go@",
|
||||
"go test ",
|
||||
"github.event.pull_request.head.ref",
|
||||
"currentPull.base.sha",
|
||||
"base_sha",
|
||||
"secrets.",
|
||||
} {
|
||||
if strings.Contains(dispatcher, forbidden) {
|
||||
t.Errorf("privileged pull_request_target dispatcher must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
failedDispatcher := workflow[failedDispatchStart:repairStart]
|
||||
for _, want := range []string{
|
||||
"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'",
|
||||
"actions: read",
|
||||
"contents: write",
|
||||
"actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b",
|
||||
"context.payload.repository?.default_branch !== 'main'",
|
||||
"eventRun?.name !== 'CI'",
|
||||
"eventRun?.event !== 'push'",
|
||||
"eventRun?.head_branch !== 'main'",
|
||||
"eventRun?.status !== 'completed'",
|
||||
"conclusion === 'success'",
|
||||
"github.rest.actions.getWorkflow",
|
||||
"workflow_id: '.github/workflows/ci.yml'",
|
||||
"github.rest.actions.getWorkflowRun",
|
||||
"eventRun.workflow_id !== ciWorkflow.id",
|
||||
"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.repository?.full_name !== upstream",
|
||||
"currentRun.head_repository?.full_name !== upstream",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-repair'",
|
||||
"source: 'failed_ci'",
|
||||
"workflow_run_id: String(runID)",
|
||||
"workflow_run_attempt: String(runAttempt)",
|
||||
"workflow_conclusion: conclusion",
|
||||
"merge_commit_sha: headSha",
|
||||
} {
|
||||
if !strings.Contains(failedDispatcher, want) {
|
||||
t.Errorf("failed-CI repair dispatcher missing %q", want)
|
||||
}
|
||||
}
|
||||
failedRunRead := strings.Index(failedDispatcher, "github.rest.actions.getWorkflowRun")
|
||||
failedRepairDispatch := strings.Index(failedDispatcher, "github.rest.repos.createDispatchEvent")
|
||||
if failedRunRead < 0 || failedRepairDispatch <= failedRunRead {
|
||||
t.Error("workflow_run dispatcher must API-bind the exact failed CI run before repository dispatch")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"actions/checkout@",
|
||||
"actions/cache/",
|
||||
"actions/setup-go@",
|
||||
"go test ",
|
||||
"secrets.",
|
||||
} {
|
||||
if strings.Contains(failedDispatcher, forbidden) {
|
||||
t.Errorf("privileged workflow_run dispatcher must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
producer := workflow[repairStart:]
|
||||
for _, want := range []string{
|
||||
"github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'",
|
||||
"timeout-minutes: 35",
|
||||
"actions: read",
|
||||
"contents: read",
|
||||
"pull-requests: read",
|
||||
"id: resolve-target",
|
||||
"context.eventName === 'repository_dispatch'",
|
||||
"payload.source === 'merged_pr'",
|
||||
"payload.pull_number",
|
||||
"payload.head_sha",
|
||||
"payload.merge_commit_sha",
|
||||
"currentPull.state === 'closed'",
|
||||
"currentPull.merged === true",
|
||||
"typeof currentPull.merged_at === 'string'",
|
||||
"currentPull.base?.ref === 'main'",
|
||||
"isStableMergedPRIdentity(",
|
||||
"currentPull.head?.sha === headSha",
|
||||
"currentPull.merge_commit_sha === mergeCommitSha",
|
||||
"payload.source === 'failed_ci'",
|
||||
"payload.workflow_run_id",
|
||||
"payload.workflow_run_attempt",
|
||||
"payload.workflow_conclusion",
|
||||
"github.rest.actions.getWorkflow",
|
||||
"workflow_id: '.github/workflows/ci.yml'",
|
||||
"github.rest.actions.getWorkflowRun",
|
||||
"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",
|
||||
"coverage-baseline-repair payload has an unknown source",
|
||||
"context.ref !== 'refs/heads/main'",
|
||||
"targetSha = context.sha",
|
||||
"for (let attempt = 1; attempt <= 6; attempt += 1)",
|
||||
"basehead: `${targetSha}...${branch.commit.sha}`",
|
||||
"core.setOutput('target_sha', targetSha)",
|
||||
"actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
|
||||
"fetch-depth: 0",
|
||||
"persist-credentials: false",
|
||||
"ref: ${{ steps.resolve-target.outputs.target_sha }}",
|
||||
`run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"`,
|
||||
"actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff",
|
||||
"id: target-cache",
|
||||
"actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
"key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}",
|
||||
"go test -count=1 -p 1",
|
||||
"./ ./cmd/... ./internal/... ./skills/...",
|
||||
"actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830",
|
||||
"id: target-cache-verification",
|
||||
"lookup-only: true",
|
||||
"fail-on-cache-miss: true",
|
||||
"EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}",
|
||||
`run: test "$EXACT_CACHE_HIT" = true`,
|
||||
} {
|
||||
if !strings.Contains(producer, want) {
|
||||
t.Errorf("trusted coverage repair producer missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"restore-keys",
|
||||
"github.event.pull_request.head.ref",
|
||||
"currentPull.base.sha",
|
||||
"base_sha",
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"RELEASE_GOVERNANCE_TOKEN",
|
||||
"REVIEWER_ROUTER_APP_PRIVATE_KEY",
|
||||
"contents: write",
|
||||
} {
|
||||
if strings.Contains(producer, forbidden) {
|
||||
t.Errorf("trusted coverage repair producer must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Count(producer, "path: coverage-cache.txt") != 3 {
|
||||
t.Error("coverage repair must restore, save, and independently verify one exact cache path/version")
|
||||
}
|
||||
if strings.Count(producer, "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}") != 3 {
|
||||
t.Error("coverage repair restore, save, and verification must share one exact target key")
|
||||
}
|
||||
stablePayloadIdentityCheck := strings.Index(producer, "if (!isStableMergedPRIdentity(")
|
||||
mainPayloadContainmentCheck := strings.Index(producer, "await requireMainContainment(targetSha)")
|
||||
resolvedTargetOutput := strings.Index(producer, "core.setOutput('target_sha', targetSha)")
|
||||
if stablePayloadIdentityCheck < 0 ||
|
||||
mainPayloadContainmentCheck <= stablePayloadIdentityCheck ||
|
||||
resolvedTargetOutput <= mainPayloadContainmentCheck {
|
||||
t.Error("merged-PR producer must prove stable payload identity and main containment before resolving checkout target")
|
||||
}
|
||||
validateIndex := strings.Index(producer, "name: Resolve trusted main repair target")
|
||||
checkoutIndex := strings.Index(producer, "name: Check out exact protected-main target")
|
||||
if validateIndex < 0 || checkoutIndex <= validateIndex {
|
||||
t.Error("repository_dispatch input must be fully bound to its protected-main source before checkout")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2431,6 +2431,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"- verify-darwin-signatures",
|
||||
"- publish-release",
|
||||
"- publish-channels",
|
||||
"- coverage-baseline-confirmation",
|
||||
"- mirror-gitee-release",
|
||||
"- repair-npm",
|
||||
"- repair-channel",
|
||||
@@ -2440,6 +2441,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
`RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}`,
|
||||
`RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`,
|
||||
`SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`,
|
||||
`COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}`,
|
||||
"require_publication",
|
||||
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
||||
`require_result release-validation "$RELEASE_VALIDATION_RESULT" success`,
|
||||
@@ -2447,6 +2449,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
`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`,
|
||||
`require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped`,
|
||||
"workflow_dispatch:recover_release",
|
||||
"workflow_dispatch:create_release",
|
||||
"workflow_dispatch:plan_release",
|
||||
@@ -2787,8 +2791,12 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, required := range []string{
|
||||
"timeout-minutes: 30",
|
||||
`coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}`,
|
||||
`coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}`,
|
||||
"id: homebrew-stable",
|
||||
"id: homebrew-beta",
|
||||
"id: seal-formula",
|
||||
`FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`,
|
||||
`FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`,
|
||||
} {
|
||||
@@ -2830,6 +2838,8 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
`head_sha: commit`,
|
||||
`status: "completed"`,
|
||||
`conclusion: "success"`,
|
||||
`core.setOutput("coverage_baseline_required", "true")`,
|
||||
`core.setOutput("coverage_baseline_commit", commit)`,
|
||||
} {
|
||||
if !strings.Contains(seal, required) {
|
||||
t.Errorf("Formula-only Code Admission sealing is missing %q", required)
|
||||
@@ -2841,12 +2851,17 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if strings.Count(seal, "github.rest.checks.create") != 1 {
|
||||
t.Error("Formula-only checks must be created only by the single verified context loop")
|
||||
t.Error("Formula sealing must write only the reviewed Code Admission contexts")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"head_sha: context.sha",
|
||||
"head_sha: parent",
|
||||
"head_sha: branch.data.commit.sha",
|
||||
"github.rest.checks.get",
|
||||
"promotionComplete",
|
||||
"setTimeout(resolve, 10000)",
|
||||
"Coverage Baseline Cache",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
} {
|
||||
if strings.Contains(seal, forbidden) {
|
||||
t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden)
|
||||
@@ -2854,6 +2869,9 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
|
||||
createCheck := strings.Index(seal, "github.rest.checks.create")
|
||||
if createCheck == -1 {
|
||||
t.Error("Formula sealing must create the verified Code Admission contexts")
|
||||
}
|
||||
for name, marker := range map[string]string{
|
||||
"single-parent Formula-only identity": "const exactFormulaCommit",
|
||||
"successful parent contexts": "invalidParentContexts.length > 0",
|
||||
@@ -2867,6 +2885,67 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowConfirmsFormulaCacheWithoutBlockingChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
||||
channelsJob := releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n")
|
||||
confirmation := releaseWorkflowSection(
|
||||
t,
|
||||
workflow,
|
||||
" coverage-baseline-confirmation:\n",
|
||||
"\n release-delivery-gate:\n",
|
||||
)
|
||||
|
||||
for _, required := range []string{
|
||||
`if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}`,
|
||||
"needs: publish-release",
|
||||
"timeout-minutes: 35",
|
||||
"checks: write",
|
||||
"contents: write",
|
||||
`BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}`,
|
||||
`FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}`,
|
||||
"if (!['true', 'false'].includes(rawRequired))",
|
||||
"Formula baseline requirement is invalid",
|
||||
"github.rest.checks.create",
|
||||
"name: 'Coverage Baseline Cache'",
|
||||
"status: 'queued'",
|
||||
"external_id: expectedExternalId",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"event_type: 'coverage-baseline-promote'",
|
||||
"source_run_id: String(context.runId)",
|
||||
"check_run_id: String(promotionCheck.id)",
|
||||
"github.rest.checks.update",
|
||||
"conclusion: 'failure'",
|
||||
"github.rest.checks.get",
|
||||
"currentCheck.head_sha !== targetSha",
|
||||
"currentCheck.external_id !== expectedExternalId",
|
||||
"currentCheck.app?.slug !== 'github-actions'",
|
||||
"for (let attempt = 1; attempt <= 180; attempt += 1)",
|
||||
"currentCheck.conclusion !== 'success'",
|
||||
"await new Promise(resolve => setTimeout(resolve, 10000))",
|
||||
"Formula baseline promotion timed out",
|
||||
} {
|
||||
if !strings.Contains(confirmation, required) {
|
||||
t.Errorf("Formula baseline confirmation missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(channelsJob, "coverage-baseline-confirmation") ||
|
||||
strings.Contains(channelsJob, "needs.coverage-baseline-confirmation") {
|
||||
t.Error("cache acknowledgement must not block npm or mirror publication")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"Coverage Baseline Cache",
|
||||
"github.rest.repos.createDispatchEvent",
|
||||
"github.rest.checks.get",
|
||||
"promotionComplete",
|
||||
} {
|
||||
if strings.Contains(publishJob, forbidden) {
|
||||
t.Errorf("irreversible publication job must not own cache lifecycle marker %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) {
|
||||
workflow := readReleaseWorkflow(t)
|
||||
script := releaseWorkflowRunScript(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const mergeDefaultsProjectionHelper = `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 privilegedMergeDefaultsGuard = `if (mergeDefaultsProjection !== 'reviewed') {
|
||||
throw new Error(
|
||||
'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.',
|
||||
);
|
||||
}`
|
||||
|
||||
func TestReviewerRouterMergeDefaultsProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify merge-default projection semantics")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
|
||||
var scripts []string
|
||||
for _, relativePath := range []string{
|
||||
filepath.Join(".github", "workflows", "ci.yml"),
|
||||
filepath.Join(".github", "workflows", "reviewer-router.yml"),
|
||||
} {
|
||||
path := filepath.Join(root, relativePath)
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", path, readErr)
|
||||
}
|
||||
var workflow any
|
||||
if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, unmarshalErr)
|
||||
}
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
readOnlyChecks := 0
|
||||
privilegedChecks := 0
|
||||
for _, script := range scripts {
|
||||
if strings.Count(script, mergeDefaultsProjectionHelper) != 1 {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
for _, marker := range []string{
|
||||
"github.rest.repos.get",
|
||||
"const mergeDefaultsProjection = classifyMergeDefaults(repository);",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("merge-default script does not enforce marker %q", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "MINTED_REVIEWER_ROUTER_APP_SLUG") {
|
||||
privilegedChecks++
|
||||
for _, marker := range []string{
|
||||
privilegedMergeDefaultsGuard,
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("privileged merge-default script missing marker %q", marker)
|
||||
}
|
||||
}
|
||||
guardIndex := strings.Index(script, privilegedMergeDefaultsGuard)
|
||||
guardEndIndex := guardIndex + len(privilegedMergeDefaultsGuard)
|
||||
firstMutationIndex := len(script)
|
||||
for _, mutation := range []string{
|
||||
"disablePullRequestAutoMerge",
|
||||
"enablePullRequestAutoMerge",
|
||||
} {
|
||||
if index := strings.Index(script, mutation); index >= 0 && index < firstMutationIndex {
|
||||
firstMutationIndex = index
|
||||
}
|
||||
}
|
||||
if guardIndex < 0 || firstMutationIndex == len(script) || guardEndIndex >= firstMutationIndex {
|
||||
t.Error("privileged merge-default validation must precede every auto-merge mutation")
|
||||
}
|
||||
continue
|
||||
}
|
||||
readOnlyChecks++
|
||||
for _, marker := range []string{
|
||||
"mergeDefaultsProjection === 'invalid'",
|
||||
"mergeDefaultsProjection === 'omitted'",
|
||||
"Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("read-only merge-default script missing marker %q", marker)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "mergeDefaultsProjection !== 'reviewed'") {
|
||||
t.Error("read-only CI must distinguish exact dual omission from malformed projections")
|
||||
}
|
||||
}
|
||||
if checked != 3 || readOnlyChecks != 1 || privilegedChecks != 2 {
|
||||
t.Fatalf(
|
||||
"merge-default scripts checked=%d read-only=%d privileged=%d, want 3/1/2",
|
||||
checked,
|
||||
readOnlyChecks,
|
||||
privilegedChecks,
|
||||
)
|
||||
}
|
||||
|
||||
verification := mergeDefaultsProjectionHelper + `
|
||||
const cases = [
|
||||
['reviewed PR title', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_TITLE'}, 'reviewed'],
|
||||
['reviewed blank body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'BLANK'}, 'reviewed'],
|
||||
['dual omission', {}, 'omitted'],
|
||||
['null repository', null, 'invalid'],
|
||||
['array repository', [], 'invalid'],
|
||||
['title only', {merge_commit_title: 'MERGE_MESSAGE'}, 'invalid'],
|
||||
['message only', {merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['own undefined title', {merge_commit_title: undefined, merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['own undefined message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: undefined}, 'invalid'],
|
||||
['null title', {merge_commit_title: null, merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['null message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: null}, 'invalid'],
|
||||
['unsafe title', {merge_commit_title: 'PR_TITLE', merge_commit_message: 'PR_TITLE'}, 'invalid'],
|
||||
['unsafe body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_BODY'}, 'invalid'],
|
||||
];
|
||||
for (const [name, repository, want] of cases) {
|
||||
const got = classifyMergeDefaults(repository);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("merge-default projection verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const appMergeFinalStateHelper = `function validateAppMergeFinalState(
|
||||
pullRequest,
|
||||
expectedHeadSha,
|
||||
expectedAppLogin,
|
||||
expectedBaseRepository,
|
||||
expectedMergeSha,
|
||||
) {
|
||||
const mergedBy = pullRequest.merged_by?.login?.toLowerCase();
|
||||
const mergeCommitSha = pullRequest.merge_commit_sha;
|
||||
const baseRepository =
|
||||
pullRequest.base?.repo?.full_name?.toLowerCase();
|
||||
const responseShaRequired = arguments.length >= 5;
|
||||
const responseShaMatches =
|
||||
!responseShaRequired ||
|
||||
(typeof expectedMergeSha === 'string' &&
|
||||
expectedMergeSha.length > 0 &&
|
||||
mergeCommitSha === expectedMergeSha);
|
||||
if (
|
||||
pullRequest.state !== 'closed' ||
|
||||
!pullRequest.merged_at ||
|
||||
pullRequest.head.sha !== expectedHeadSha ||
|
||||
pullRequest.base?.ref !== 'main' ||
|
||||
baseRepository !== expectedBaseRepository ||
|
||||
mergedBy !== expectedAppLogin ||
|
||||
typeof mergeCommitSha !== 'string' ||
|
||||
!mergeCommitSha ||
|
||||
!responseShaMatches
|
||||
) {
|
||||
throw new Error(
|
||||
` + "`merge result was not attributed exactly to ${expectedAppLogin}`" + `,
|
||||
);
|
||||
}
|
||||
return {mergedBy, mergeCommitSha};
|
||||
}`
|
||||
|
||||
const successfulMergeResponseHelper = `function requireSuccessfulMergeResponse(mergeResult) {
|
||||
if (mergeResult?.merged !== true) {
|
||||
throw new Error(
|
||||
` + "`GitHub returned a successful response without merging: ${mergeResult?.message || 'no explanation'}`" + `,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof mergeResult.sha !== 'string' ||
|
||||
!mergeResult.sha
|
||||
) {
|
||||
throw new Error(
|
||||
'GitHub returned a successful merge without a merge commit SHA.',
|
||||
);
|
||||
}
|
||||
return mergeResult.sha;
|
||||
}`
|
||||
|
||||
const mergeAttemptRecoveryHelper = `function classifyMergeAttemptRecovery(
|
||||
status,
|
||||
latestPull,
|
||||
expectedHeadSha,
|
||||
expectedAppLogin,
|
||||
expectedBaseRepository,
|
||||
responseMergeSha,
|
||||
) {
|
||||
if (latestPull.merged_at) {
|
||||
const finalState = responseMergeSha === undefined
|
||||
? validateAppMergeFinalState(
|
||||
latestPull,
|
||||
expectedHeadSha,
|
||||
expectedAppLogin,
|
||||
expectedBaseRepository,
|
||||
)
|
||||
: validateAppMergeFinalState(
|
||||
latestPull,
|
||||
expectedHeadSha,
|
||||
expectedAppLogin,
|
||||
expectedBaseRepository,
|
||||
responseMergeSha,
|
||||
);
|
||||
return {outcome: 'merged', finalState};
|
||||
}
|
||||
if (latestPull.state !== 'open') {
|
||||
return {outcome: 'closed'};
|
||||
}
|
||||
if (status === 404) {
|
||||
throw new Error(
|
||||
'merge endpoint returned 404 while the pull request remained open',
|
||||
);
|
||||
}
|
||||
if (status === 405 || status === 409) {
|
||||
return {outcome: 'not_ready'};
|
||||
}
|
||||
throw new Error(` + "`unsupported merge recovery status ${status}`" + `);
|
||||
}`
|
||||
|
||||
func TestReviewerRouterValidatesAppMergeFinalState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify App merge final-state semantics")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".github", "workflows", "reviewer-router.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var workflow any
|
||||
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
var scripts []string
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
checked := 0
|
||||
for _, script := range scripts {
|
||||
if strings.Contains(script, "function validateAppMergeFinalState(") {
|
||||
checked++
|
||||
if got := strings.Count(script, appMergeFinalStateHelper); got != 1 {
|
||||
t.Errorf("App merge final-state helper count = %d, want 1", got)
|
||||
}
|
||||
if got := strings.Count(script, successfulMergeResponseHelper); got != 1 {
|
||||
t.Errorf("successful merge-response helper count = %d, want 1", got)
|
||||
}
|
||||
if got := strings.Count(script, mergeAttemptRecoveryHelper); got != 1 {
|
||||
t.Errorf("merge-attempt recovery helper count = %d, want 1", got)
|
||||
}
|
||||
if got := strings.Count(script, "validateAppMergeFinalState("); got != 6 {
|
||||
t.Errorf("App merge final-state helper/calls = %d, want one helper plus five guarded final-state calls", got)
|
||||
}
|
||||
for _, marker := range []string{"currentPull,", "mergedPull,", "latestPull,"} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("reconciliation script is missing final-state validation marker %q", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 1 {
|
||||
t.Fatalf("App merge final-state scripts checked = %d, want 1", checked)
|
||||
}
|
||||
|
||||
verification := appMergeFinalStateHelper + "\n" + successfulMergeResponseHelper + "\n" + mergeAttemptRecoveryHelper + `
|
||||
const app = 'dingtalk-dws-reviewer-router[bot]';
|
||||
const valid = {
|
||||
state: 'closed',
|
||||
merged_at: '2026-08-25T00:00:00Z',
|
||||
head: {sha: 'head-sha'},
|
||||
base: {
|
||||
ref: 'main',
|
||||
repo: {full_name: 'DingTalk-Real-AI/dingtalk-workspace-cli'},
|
||||
},
|
||||
merged_by: {login: 'DingTalk-DWS-Reviewer-Router[bot]'},
|
||||
merge_commit_sha: 'merge-sha',
|
||||
};
|
||||
const repository = 'dingtalk-real-ai/dingtalk-workspace-cli';
|
||||
const observed = validateAppMergeFinalState(valid, 'head-sha', app, repository);
|
||||
if (observed.mergedBy !== app || observed.mergeCommitSha !== 'merge-sha') {
|
||||
throw new Error('valid concurrent App merge did not preserve final identity');
|
||||
}
|
||||
validateAppMergeFinalState(valid, 'head-sha', app, repository, 'merge-sha');
|
||||
|
||||
const invalidCases = [
|
||||
['open PR', {...valid, state: 'open'}],
|
||||
['missing merged_at', {...valid, merged_at: null}],
|
||||
['wrong head', {...valid, head: {sha: 'other-head'}}],
|
||||
['wrong base ref', {...valid, base: {...valid.base, ref: 'release'}}],
|
||||
['wrong base repository', {...valid, base: {...valid.base, repo: {full_name: 'other/repo'}}}],
|
||||
['missing base repository', {...valid, base: {ref: 'main', repo: null}}],
|
||||
['human merger', {...valid, merged_by: {login: 'haofeng0705'}}],
|
||||
['missing merger', {...valid, merged_by: null}],
|
||||
['empty merge SHA', {...valid, merge_commit_sha: ''}],
|
||||
['missing merge SHA', {...valid, merge_commit_sha: null}],
|
||||
];
|
||||
for (const [name, pull] of invalidCases) {
|
||||
let rejected = false;
|
||||
try {
|
||||
validateAppMergeFinalState(pull, 'head-sha', app, repository);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) {
|
||||
throw new Error(name + ' was accepted');
|
||||
}
|
||||
}
|
||||
for (const responseSha of [undefined, '', 'other-merge', null]) {
|
||||
let rejected = false;
|
||||
try {
|
||||
validateAppMergeFinalState(valid, 'head-sha', app, repository, responseSha);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) {
|
||||
throw new Error('invalid response SHA was accepted: ' + responseSha);
|
||||
}
|
||||
}
|
||||
|
||||
if (requireSuccessfulMergeResponse({merged: true, sha: 'merge-sha'}) !== 'merge-sha') {
|
||||
throw new Error('successful merge response did not return its SHA');
|
||||
}
|
||||
for (const response of [
|
||||
{merged: false, sha: 'merge-sha', message: 'not ready'},
|
||||
{merged: true},
|
||||
{merged: true, sha: undefined},
|
||||
{merged: true, sha: ''},
|
||||
]) {
|
||||
let rejected = false;
|
||||
try {
|
||||
requireSuccessfulMergeResponse(response);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) {
|
||||
throw new Error('invalid successful merge response was accepted: ' + JSON.stringify(response));
|
||||
}
|
||||
}
|
||||
|
||||
for (const status of [405, 409]) {
|
||||
const recovery = classifyMergeAttemptRecovery(
|
||||
status,
|
||||
{...valid, state: 'open', merged_at: null, merge_commit_sha: null},
|
||||
'head-sha',
|
||||
app,
|
||||
repository,
|
||||
undefined,
|
||||
);
|
||||
if (recovery.outcome !== 'not_ready') {
|
||||
throw new Error(status + ' open PR did not remain retriable');
|
||||
}
|
||||
}
|
||||
for (const responseSha of [undefined, 'merge-sha']) {
|
||||
const recovery = classifyMergeAttemptRecovery(
|
||||
409,
|
||||
valid,
|
||||
'head-sha',
|
||||
app,
|
||||
repository,
|
||||
responseSha,
|
||||
);
|
||||
if (recovery.outcome !== 'merged' || recovery.finalState.mergeCommitSha !== 'merge-sha') {
|
||||
throw new Error('valid concurrent App merge was not accepted');
|
||||
}
|
||||
}
|
||||
for (const status of [404, 405, 409]) {
|
||||
const recovery = classifyMergeAttemptRecovery(
|
||||
status,
|
||||
{...valid, merged_at: null, merge_commit_sha: null},
|
||||
'head-sha',
|
||||
app,
|
||||
repository,
|
||||
undefined,
|
||||
);
|
||||
if (recovery.outcome !== 'closed') {
|
||||
throw new Error(status + ' closed-unmerged PR did not remain safely closed');
|
||||
}
|
||||
}
|
||||
for (const [name, status, pull, responseSha] of [
|
||||
['open 404', 404, {...valid, state: 'open', merged_at: null}, undefined],
|
||||
['unsupported status', 500, {...valid, state: 'open', merged_at: null}, undefined],
|
||||
['human concurrent merge', 409, {...valid, merged_by: {login: 'haofeng0705'}}, undefined],
|
||||
['mismatched successful response SHA', 404, valid, 'other-merge'],
|
||||
]) {
|
||||
let rejected = false;
|
||||
try {
|
||||
classifyMergeAttemptRecovery(
|
||||
status,
|
||||
pull,
|
||||
'head-sha',
|
||||
app,
|
||||
repository,
|
||||
responseSha,
|
||||
);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) {
|
||||
throw new Error(name + ' was accepted');
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("App merge final-state verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const reviewerRouterRequiredRulesetHelpers = `function hasExactStringSet(values, expected) {
|
||||
return (
|
||||
Array.isArray(values) &&
|
||||
values.length === expected.length &&
|
||||
new Set(values).size === expected.length &&
|
||||
expected.every(value => values.includes(value))
|
||||
);
|
||||
}
|
||||
function hasExactRequiredStatusChecks(checks) {
|
||||
if (
|
||||
!Array.isArray(checks) ||
|
||||
checks.length !== requiredCheckContexts.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const contexts = checks.map(check => check?.context);
|
||||
return (
|
||||
new Set(contexts).size === requiredCheckContexts.length &&
|
||||
checks.every(
|
||||
check =>
|
||||
requiredCheckContexts.includes(check?.context) &&
|
||||
check?.integration_id === requiredCheckIntegrationID,
|
||||
)
|
||||
);
|
||||
}
|
||||
function hasExactMainRulesetScope(ruleset) {
|
||||
const includes = ruleset?.conditions?.ref_name?.include;
|
||||
const excludes = ruleset?.conditions?.ref_name?.exclude;
|
||||
return (
|
||||
ruleset?.enforcement === 'active' &&
|
||||
ruleset?.target === 'branch' &&
|
||||
ruleset?.source_type === 'Repository' &&
|
||||
ruleset?.source?.toLowerCase() === repositorySource &&
|
||||
Array.isArray(includes) &&
|
||||
includes.length === 1 &&
|
||||
includes[0] === 'refs/heads/main' &&
|
||||
Array.isArray(excludes) &&
|
||||
excludes.length === 0
|
||||
);
|
||||
}
|
||||
function isExactMainProtectionRuleset(ruleset) {
|
||||
if (
|
||||
!hasExactMainRulesetScope(ruleset) ||
|
||||
!Array.isArray(ruleset.rules) ||
|
||||
ruleset.rules.length !== 3 ||
|
||||
!hasExactStringSet(
|
||||
ruleset.rules.map(rule => rule?.type),
|
||||
['deletion', 'non_fast_forward', 'pull_request'],
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const pullRequestRule = ruleset.rules.find(
|
||||
rule => rule.type === 'pull_request',
|
||||
);
|
||||
const parameters = pullRequestRule?.parameters;
|
||||
return (
|
||||
parameters?.required_approving_review_count === 1 &&
|
||||
parameters.require_last_push_approval === true &&
|
||||
parameters.require_extra_approval_for_unattributed_changes === true &&
|
||||
parameters.dismiss_stale_reviews_on_push === false &&
|
||||
parameters.require_code_owner_review === false &&
|
||||
parameters.required_review_thread_resolution === false &&
|
||||
hasExactStringSet(
|
||||
parameters.allowed_merge_methods,
|
||||
['merge', 'squash', 'rebase'],
|
||||
) &&
|
||||
parameters.dismissal_restriction?.enabled === false &&
|
||||
Array.isArray(parameters.dismissal_restriction.allowed_actors) &&
|
||||
parameters.dismissal_restriction.allowed_actors.length === 0 &&
|
||||
Array.isArray(parameters.required_reviewers) &&
|
||||
parameters.required_reviewers.length === 0
|
||||
);
|
||||
}
|
||||
function isExactMainQualityRuleset(ruleset) {
|
||||
if (
|
||||
!hasExactMainRulesetScope(ruleset) ||
|
||||
!Array.isArray(ruleset.rules) ||
|
||||
ruleset.rules.length !== 1 ||
|
||||
ruleset.rules[0]?.type !== 'required_status_checks'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const parameters = ruleset.rules[0].parameters;
|
||||
return (
|
||||
parameters?.strict_required_status_checks_policy === true &&
|
||||
parameters.do_not_enforce_on_create === false &&
|
||||
hasExactRequiredStatusChecks(
|
||||
parameters.required_status_checks,
|
||||
)
|
||||
);
|
||||
}`
|
||||
|
||||
func TestReviewerRouterRequiresExactApprovalAndQualityRulesets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify approval and quality ruleset semantics")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
path := filepath.Join(root, ".github", "workflows", "reviewer-router.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var workflow any
|
||||
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
var scripts []string
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
checked := 0
|
||||
for _, script := range scripts {
|
||||
if !strings.Contains(script, "function isExactMainProtectionRuleset(") {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if got := strings.Count(script, reviewerRouterRequiredRulesetHelpers); got != 1 {
|
||||
t.Errorf("approval/quality ruleset helper count = %d, want 1", got)
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"const requiredCheckIntegrationID = 15368;",
|
||||
"protectionRulesets.length !== 1",
|
||||
"protectionRulesets[0].current_user_can_bypass !== 'never'",
|
||||
"!isExactMainProtectionRuleset(protectionRulesets[0])",
|
||||
"qualityRulesets.length !== 1",
|
||||
"qualityRulesets[0].current_user_can_bypass !== 'never'",
|
||||
"!isExactMainQualityRuleset(qualityRulesets[0])",
|
||||
"exact non-bypassable main-protection approval ruleset",
|
||||
"exact non-bypassable main-quality ruleset with nine strict GitHub Actions checks",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("reconciliation script is missing live ruleset contract marker %q", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 1 {
|
||||
t.Fatalf("approval/quality ruleset scripts checked = %d, want 1 privileged reconcile script", checked)
|
||||
}
|
||||
|
||||
verification := `
|
||||
const repositorySource = 'dingtalk-real-ai/dingtalk-workspace-cli';
|
||||
const requiredCheckContexts = [
|
||||
'Lint',
|
||||
'Test',
|
||||
'Coverage',
|
||||
'Policy',
|
||||
'Edition',
|
||||
'Interface Integrity',
|
||||
'AI Behavior',
|
||||
'CLI Smoke',
|
||||
'Mock MCP',
|
||||
];
|
||||
const requiredCheckIntegrationID = 15368;
|
||||
` + reviewerRouterRequiredRulesetHelpers + `
|
||||
const clone = value => JSON.parse(JSON.stringify(value));
|
||||
const scope = {
|
||||
enforcement: 'active',
|
||||
target: 'branch',
|
||||
source_type: 'Repository',
|
||||
source: 'DingTalk-Real-AI/dingtalk-workspace-cli',
|
||||
conditions: {ref_name: {include: ['refs/heads/main'], exclude: []}},
|
||||
current_user_can_bypass: 'never',
|
||||
};
|
||||
const protection = {
|
||||
...scope,
|
||||
name: 'main-protection',
|
||||
rules: [
|
||||
{type: 'deletion'},
|
||||
{type: 'non_fast_forward'},
|
||||
{
|
||||
type: 'pull_request',
|
||||
parameters: {
|
||||
allowed_merge_methods: ['merge', 'squash', 'rebase'],
|
||||
dismiss_stale_reviews_on_push: false,
|
||||
dismissal_restriction: {allowed_actors: [], enabled: false},
|
||||
require_code_owner_review: false,
|
||||
require_extra_approval_for_unattributed_changes: true,
|
||||
require_last_push_approval: true,
|
||||
required_approving_review_count: 1,
|
||||
required_review_thread_resolution: false,
|
||||
required_reviewers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const quality = {
|
||||
...scope,
|
||||
name: 'main-quality',
|
||||
rules: [{
|
||||
type: 'required_status_checks',
|
||||
parameters: {
|
||||
do_not_enforce_on_create: false,
|
||||
strict_required_status_checks_policy: true,
|
||||
required_status_checks: requiredCheckContexts.map(context => ({
|
||||
context,
|
||||
integration_id: requiredCheckIntegrationID,
|
||||
})),
|
||||
},
|
||||
}],
|
||||
};
|
||||
if (!isExactMainProtectionRuleset(protection)) {
|
||||
throw new Error('valid main-protection ruleset was rejected');
|
||||
}
|
||||
if (!isExactMainQualityRuleset(quality)) {
|
||||
throw new Error('valid main-quality ruleset was rejected');
|
||||
}
|
||||
|
||||
const invalidProtection = [];
|
||||
let candidate = clone(protection);
|
||||
candidate.enforcement = 'disabled';
|
||||
invalidProtection.push(['disabled', candidate]);
|
||||
candidate = clone(protection);
|
||||
candidate.conditions.ref_name.include = ['~ALL'];
|
||||
invalidProtection.push(['wrong scope', candidate]);
|
||||
candidate = clone(protection);
|
||||
candidate.rules = candidate.rules.filter(rule => rule.type !== 'pull_request');
|
||||
invalidProtection.push(['missing pull-request rule', candidate]);
|
||||
for (const [name, key, value] of [
|
||||
['missing approval', 'required_approving_review_count', 0],
|
||||
['latest-push approval disabled', 'require_last_push_approval', false],
|
||||
['unattributed approval disabled', 'require_extra_approval_for_unattributed_changes', false],
|
||||
]) {
|
||||
candidate = clone(protection);
|
||||
candidate.rules.find(rule => rule.type === 'pull_request').parameters[key] = value;
|
||||
invalidProtection.push([name, candidate]);
|
||||
}
|
||||
for (const [name, value] of invalidProtection) {
|
||||
if (isExactMainProtectionRuleset(value)) {
|
||||
throw new Error(name + ' main-protection ruleset was accepted');
|
||||
}
|
||||
}
|
||||
|
||||
const invalidQuality = [];
|
||||
candidate = clone(quality);
|
||||
candidate.enforcement = 'disabled';
|
||||
invalidQuality.push(['disabled', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules = [];
|
||||
invalidQuality.push(['missing status-check rule', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.strict_required_status_checks_policy = false;
|
||||
invalidQuality.push(['non-strict', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.do_not_enforce_on_create = true;
|
||||
invalidQuality.push(['create bypass', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.required_status_checks.pop();
|
||||
invalidQuality.push(['missing check', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.required_status_checks[8].context = 'Lint';
|
||||
invalidQuality.push(['duplicate check', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.required_status_checks.push({
|
||||
context: 'Unexpected',
|
||||
integration_id: requiredCheckIntegrationID,
|
||||
});
|
||||
invalidQuality.push(['extra check', candidate]);
|
||||
candidate = clone(quality);
|
||||
delete candidate.rules[0].parameters.required_status_checks[0].integration_id;
|
||||
invalidQuality.push(['missing integration', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.required_status_checks[0].integration_id = 1;
|
||||
invalidQuality.push(['wrong integration', candidate]);
|
||||
candidate = clone(quality);
|
||||
candidate.rules[0].parameters.required_status_checks[8] = {
|
||||
context: 'Lint',
|
||||
integration_id: 1,
|
||||
};
|
||||
invalidQuality.push(['duplicate context from another integration', candidate]);
|
||||
for (const [name, value] of invalidQuality) {
|
||||
if (isExactMainQualityRuleset(value)) {
|
||||
throw new Error(name + ' main-quality ruleset was accepted');
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("approval/quality ruleset verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const strictUpdateRuleHelper = `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
|
||||
);
|
||||
}`
|
||||
|
||||
const strictGraphQLUpdateRuleHelper = `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
|
||||
);
|
||||
}`
|
||||
|
||||
func TestReviewerRouterAcceptsGitHubStrictUpdateReadProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
node, err := exec.LookPath("node")
|
||||
if err != nil {
|
||||
t.Skip("node is required to verify the ruleset projection helper")
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
|
||||
var scripts []string
|
||||
for _, relativePath := range []string{
|
||||
filepath.Join(".github", "workflows", "ci.yml"),
|
||||
filepath.Join(".github", "workflows", "reviewer-router.yml"),
|
||||
} {
|
||||
path := filepath.Join(root, relativePath)
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", path, readErr)
|
||||
}
|
||||
var workflow any
|
||||
if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil {
|
||||
t.Fatalf("parse %s: %v", path, unmarshalErr)
|
||||
}
|
||||
collectGitHubScripts(workflow, &scripts)
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for _, script := range scripts {
|
||||
if !strings.Contains(script, "const writerRuleset = writerRulesets[0];") {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if got := strings.Count(script, strictUpdateRuleHelper); got != 1 {
|
||||
t.Errorf("writer-ruleset script contains strict update helper %d times, want 1", got)
|
||||
}
|
||||
if got := strings.Count(script, strictGraphQLUpdateRuleHelper); got != 1 {
|
||||
t.Errorf("writer-ruleset script contains GraphQL strict update helper %d times, want 1", got)
|
||||
}
|
||||
if strings.Contains(
|
||||
script,
|
||||
"parameters?.update_allows_fetch_and_merge !== false",
|
||||
) {
|
||||
t.Error("writer-ruleset script rejects GitHub's omitted strict-false read projection")
|
||||
}
|
||||
if !strings.Contains(script, "!isStrictUpdateRule(writerRuleset.rules[0])") {
|
||||
t.Error("writer-ruleset script does not enforce the strict update helper")
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"rules(first: 2)",
|
||||
"updateAllowsFetchAndMerge",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Errorf("writer-ruleset script does not enforce GraphQL marker %q", marker)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("writer-ruleset scripts checked = %d, want App enable, reconcile, and CI self-check", checked)
|
||||
}
|
||||
|
||||
verification := strictUpdateRuleHelper + "\n" + strictGraphQLUpdateRuleHelper + `
|
||||
const cases = [
|
||||
['omitted parameters', {type: 'update'}, true],
|
||||
['explicit false', {type: 'update', parameters: {update_allows_fetch_and_merge: false}}, true],
|
||||
['null parameters', {type: 'update', parameters: null}, false],
|
||||
['empty parameters', {type: 'update', parameters: {}}, false],
|
||||
['boolean parameters', {type: 'update', parameters: false}, false],
|
||||
['string parameters', {type: 'update', parameters: 'malformed'}, false],
|
||||
['array parameters', {type: 'update', parameters: []}, false],
|
||||
['explicit true', {type: 'update', parameters: {update_allows_fetch_and_merge: true}}, false],
|
||||
['null field', {type: 'update', parameters: {update_allows_fetch_and_merge: null}}, false],
|
||||
['string false', {type: 'update', parameters: {update_allows_fetch_and_merge: 'false'}}, false],
|
||||
['numeric false', {type: 'update', parameters: {update_allows_fetch_and_merge: 0}}, false],
|
||||
['extra parameter', {type: 'update', parameters: {update_allows_fetch_and_merge: false, future_exception: false}}, false],
|
||||
['wrong rule type', {type: 'creation'}, false],
|
||||
['null rule', null, false],
|
||||
];
|
||||
for (const [name, rule, want] of cases) {
|
||||
const got = isStrictUpdateRule(rule);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
|
||||
const restRuleset = {id: 21363804, name: 'main-merge-writers'};
|
||||
const graphRule = {
|
||||
type: 'UPDATE',
|
||||
parameters: {
|
||||
__typename: 'UpdateParameters',
|
||||
updateAllowsFetchAndMerge: false,
|
||||
},
|
||||
};
|
||||
const graphRuleset = {
|
||||
databaseId: 21363804,
|
||||
name: 'main-merge-writers',
|
||||
enforcement: 'ACTIVE',
|
||||
target: 'BRANCH',
|
||||
rules: {totalCount: 1, nodes: [graphRule]},
|
||||
};
|
||||
const graphCases = [
|
||||
['valid GraphQL projection', restRuleset, graphRuleset, true],
|
||||
['invalid REST id', {id: 0, name: 'main-merge-writers'}, graphRuleset, false],
|
||||
['mismatched GraphQL id', restRuleset, {...graphRuleset, databaseId: 7}, false],
|
||||
['mismatched name', restRuleset, {...graphRuleset, name: 'other'}, false],
|
||||
['inactive ruleset', restRuleset, {...graphRuleset, enforcement: 'EVALUATE'}, false],
|
||||
['wrong target', restRuleset, {...graphRuleset, target: 'TAG'}, false],
|
||||
['extra rule', restRuleset, {...graphRuleset, rules: {totalCount: 2, nodes: [graphRule, graphRule]}}, false],
|
||||
['wrong rule type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, type: 'CREATION'}]}}, false],
|
||||
['wrong parameter type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, __typename: 'PullRequestParameters'}}]}}, false],
|
||||
['fetch and merge enabled', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, updateAllowsFetchAndMerge: true}}]}}, false],
|
||||
['missing GraphQL node', restRuleset, null, false],
|
||||
];
|
||||
for (const [name, rest, graph, want] of graphCases) {
|
||||
const got = isStrictGraphQLUpdateRule(rest, graph);
|
||||
if (got !== want) {
|
||||
throw new Error(name + ': got ' + got + ', want ' + want);
|
||||
}
|
||||
}
|
||||
`
|
||||
command := exec.Command(node, "-e", verification)
|
||||
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||
t.Fatalf("strict update projection verification failed: %v\n%s", runErr, output)
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,45 @@ import (
|
||||
type reviewerRouterWorkflow struct {
|
||||
On map[string]reviewerRouterTrigger `yaml:"on"`
|
||||
Permissions map[string]string `yaml:"permissions"`
|
||||
Concurrency map[string]string `yaml:"concurrency"`
|
||||
Jobs map[string]reviewerRouterJob `yaml:"jobs"`
|
||||
}
|
||||
|
||||
type reviewerRouterTrigger struct {
|
||||
Branches []string `yaml:"branches"`
|
||||
Types []string `yaml:"types"`
|
||||
Branches []string `yaml:"branches"`
|
||||
Types []string `yaml:"types"`
|
||||
Workflows []string `yaml:"workflows"`
|
||||
Schedules []reviewerRouterSchedule `yaml:"-"`
|
||||
}
|
||||
|
||||
type reviewerRouterSchedule struct {
|
||||
Cron string `yaml:"cron"`
|
||||
}
|
||||
|
||||
func (trigger *reviewerRouterTrigger) UnmarshalYAML(node *yaml.Node) error {
|
||||
*trigger = reviewerRouterTrigger{}
|
||||
if node.Kind == yaml.SequenceNode {
|
||||
return node.Decode(&trigger.Schedules)
|
||||
}
|
||||
if node.Kind == yaml.ScalarNode && node.Tag == "!!null" {
|
||||
return nil
|
||||
}
|
||||
type plain reviewerRouterTrigger
|
||||
return node.Decode((*plain)(trigger))
|
||||
}
|
||||
|
||||
type reviewerRouterJob struct {
|
||||
If string `yaml:"if"`
|
||||
Steps []reviewerRouterStep `yaml:"steps"`
|
||||
If string `yaml:"if"`
|
||||
Permissions map[string]string `yaml:"permissions"`
|
||||
Steps []reviewerRouterStep `yaml:"steps"`
|
||||
}
|
||||
|
||||
type reviewerRouterStep struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Uses string `yaml:"uses"`
|
||||
Run string `yaml:"run"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
With map[string]string `yaml:"with"`
|
||||
}
|
||||
|
||||
@@ -68,8 +92,8 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if len(workflow.On) != 1 {
|
||||
t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On)
|
||||
if len(workflow.On) != 5 {
|
||||
t.Fatalf("workflow triggers = %v, want routing plus review/check-driven reconciliation", workflow.On)
|
||||
}
|
||||
trigger, ok := workflow.On["pull_request_target"]
|
||||
if !ok {
|
||||
@@ -78,47 +102,118 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
if wantBranches := []string{"main"}; !reflect.DeepEqual(trigger.Branches, wantBranches) {
|
||||
t.Fatalf("pull_request_target branches = %v, want %v", trigger.Branches, wantBranches)
|
||||
}
|
||||
wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review"}
|
||||
wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review", "edited", "auto_merge_enabled"}
|
||||
if !reflect.DeepEqual(trigger.Types, wantTypes) {
|
||||
t.Fatalf("pull_request_target types = %v, want %v", trigger.Types, wantTypes)
|
||||
}
|
||||
if _, ok := workflow.On["workflow_dispatch"]; !ok {
|
||||
t.Fatalf("workflow triggers = %v, want workflow_dispatch reconciliation", workflow.On)
|
||||
}
|
||||
workflowRun, ok := workflow.On["workflow_run"]
|
||||
if !ok ||
|
||||
!reflect.DeepEqual(workflowRun.Workflows, []string{"CI", "Code Admission — AI Behavior", "Reviewer Router approval signal"}) ||
|
||||
!reflect.DeepEqual(workflowRun.Types, []string{"completed"}) {
|
||||
t.Fatalf("workflow_run trigger = %v, want completed admission and approval-signal workflows", workflowRun)
|
||||
}
|
||||
push, ok := workflow.On["push"]
|
||||
if !ok || !reflect.DeepEqual(push.Branches, []string{"main"}) {
|
||||
t.Fatalf("push trigger = %v, want protected main only", push)
|
||||
}
|
||||
schedule, ok := workflow.On["schedule"]
|
||||
if !ok || !reflect.DeepEqual(schedule.Schedules, []reviewerRouterSchedule{{Cron: "17,47 * * * *"}}) {
|
||||
t.Fatalf("schedule trigger = %v, want staggered recovery twice per hour", schedule)
|
||||
}
|
||||
|
||||
wantPermissions := map[string]string{
|
||||
"contents": "write",
|
||||
"contents": "read",
|
||||
"pull-requests": "write",
|
||||
}
|
||||
if !reflect.DeepEqual(workflow.Permissions, wantPermissions) {
|
||||
t.Fatalf("workflow permissions = %v, want exactly %v", workflow.Permissions, wantPermissions)
|
||||
}
|
||||
wantConcurrency := map[string]string{
|
||||
"group": "reviewer-router-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || 'reconcile-main' }}",
|
||||
"cancel-in-progress": "${{ github.event_name == 'pull_request_target' }}",
|
||||
}
|
||||
if !reflect.DeepEqual(workflow.Concurrency, wantConcurrency) {
|
||||
t.Fatalf("workflow concurrency = %v, want serialized reconciliation and cancellable PR events %v", workflow.Concurrency, wantConcurrency)
|
||||
}
|
||||
|
||||
if len(workflow.Jobs) != 1 {
|
||||
t.Fatalf("workflow jobs = %v, want one isolated routing job", workflow.Jobs)
|
||||
if len(workflow.Jobs) != 3 {
|
||||
t.Fatalf("workflow jobs = %v, want event routing plus controlled reconciliation", workflow.Jobs)
|
||||
}
|
||||
job, ok := workflow.Jobs["route"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want route", workflow.Jobs)
|
||||
}
|
||||
if job.If != "github.event.pull_request.draft == false" {
|
||||
if job.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" {
|
||||
t.Fatalf("route.if = %q, want non-draft guard", job.If)
|
||||
}
|
||||
const checkoutSHA = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683"
|
||||
const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b"
|
||||
if len(job.Steps) != 2 || job.Steps[0].Uses != checkoutSHA || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[0].With["persist-credentials"] != "false" || job.Steps[1].Uses != githubScriptSHA {
|
||||
t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps)
|
||||
const appTokenSHA = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1"
|
||||
if len(job.Steps) != 2 ||
|
||||
job.Steps[0].Uses != checkoutSHA ||
|
||||
job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" ||
|
||||
job.Steps[0].With["persist-credentials"] != "false" ||
|
||||
job.Steps[1].Uses != githubScriptSHA {
|
||||
t.Fatalf("route steps = %#v, want trusted base checkout followed by routing only", job.Steps)
|
||||
}
|
||||
|
||||
script := job.Steps[1].With["script"]
|
||||
autoMergeJob, ok := workflow.Jobs["manage-auto-merge"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want manage-auto-merge", workflow.Jobs)
|
||||
}
|
||||
if autoMergeJob.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" {
|
||||
t.Fatalf("manage-auto-merge.if = %q, want exact ready event guard", autoMergeJob.If)
|
||||
}
|
||||
wantAutoPermissions := map[string]string{
|
||||
"contents": "write",
|
||||
"pull-requests": "write",
|
||||
}
|
||||
if !reflect.DeepEqual(autoMergeJob.Permissions, wantAutoPermissions) {
|
||||
t.Fatalf("manage-auto-merge permissions = %v, want exactly %v", autoMergeJob.Permissions, wantAutoPermissions)
|
||||
}
|
||||
if len(autoMergeJob.Steps) != 3 ||
|
||||
autoMergeJob.Steps[0].Uses != githubScriptSHA ||
|
||||
autoMergeJob.Steps[1].ID != "reviewer-router-token" ||
|
||||
autoMergeJob.Steps[1].Uses != appTokenSHA ||
|
||||
autoMergeJob.Steps[2].Uses != githubScriptSHA {
|
||||
t.Fatalf("manage-auto-merge steps = %#v, want unsafe-owner cleanup before dedicated App enable", autoMergeJob.Steps)
|
||||
}
|
||||
|
||||
appToken := autoMergeJob.Steps[1]
|
||||
if len(appToken.With) != 6 {
|
||||
t.Fatalf("reviewer-router token inputs = %v, want exactly the reviewed repository scope and two permissions", appToken.With)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}",
|
||||
"private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}",
|
||||
"owner": "${{ github.repository_owner }}",
|
||||
"repositories": "${{ github.event.repository.name }}",
|
||||
"permission-contents": "write",
|
||||
"permission-pull-requests": "write",
|
||||
} {
|
||||
if got := appToken.With[key]; got != want {
|
||||
t.Errorf("reviewer-router token input %q = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := appToken.With["skip-token-revoke"]; ok {
|
||||
t.Error("reviewer-router App token must be revoked automatically after the job")
|
||||
}
|
||||
|
||||
routingScript := job.Steps[1].With["script"]
|
||||
for _, want := range []string{
|
||||
"REVIEWER_POOL",
|
||||
"resolveReviewRouting",
|
||||
"github.rest.pulls.get",
|
||||
"github.rest.pulls.listFiles",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"getReadyEventPull('review request')",
|
||||
"getReadyEventPull('auto-merge enable')",
|
||||
"context.payload.action === 'synchronize'",
|
||||
"context.payload.sender?.login?.toLowerCase()",
|
||||
"reviewer.toLowerCase() !== author",
|
||||
@@ -142,22 +237,578 @@ func TestReviewerRouterWorkflowContract(t *testing.T) {
|
||||
"github.rest.pulls.requestReviewers",
|
||||
"trying the next candidate",
|
||||
"Reviewer routing hit an unexpected error",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"core.warning",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
if !strings.Contains(routingScript, want) {
|
||||
t.Errorf("reviewer router script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"enablePullRequestAutoMerge",
|
||||
"disablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
} {
|
||||
if strings.Contains(routingScript, forbidden) {
|
||||
t.Errorf("built-in routing token must not control auto-merge through %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupStep := autoMergeJob.Steps[0]
|
||||
if _, ok := cleanupStep.With["github-token"]; ok {
|
||||
t.Error("unsafe-owner cleanup must use only the job-scoped built-in token")
|
||||
}
|
||||
cleanupScript := cleanupStep.With["script"]
|
||||
for _, want := range []string{
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"const unsafeOwner = 'github-actions[bot]'",
|
||||
"const skipWorkflowPattern =",
|
||||
"currentPull.title",
|
||||
"currentPull.auto_merge?.commit_title",
|
||||
"currentPull.auto_merge?.commit_message",
|
||||
"const skipRequested =",
|
||||
"enabledBy !== unsafeOwner",
|
||||
"disablePullRequestAutoMerge",
|
||||
"cleanedPull.auto_merge",
|
||||
"Cleared workflow-skipping auto-merge metadata",
|
||||
"throw new Error",
|
||||
} {
|
||||
if !strings.Contains(cleanupScript, want) {
|
||||
t.Errorf("unsafe-owner cleanup is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(cleanupScript, "enablePullRequestAutoMerge") {
|
||||
t.Error("built-in cleanup token must never enable auto-merge")
|
||||
}
|
||||
if got := strings.Count(cleanupScript, "const skipWorkflowPattern ="); got != 1 {
|
||||
t.Errorf("unsafe-request cleanup skip-workflow pattern declarations = %d, want exactly 1", got)
|
||||
}
|
||||
|
||||
autoMergeStep := autoMergeJob.Steps[2]
|
||||
if got, want := autoMergeStep.With["github-token"], "${{ steps.reviewer-router-token.outputs.token }}"; got != want {
|
||||
t.Fatalf("auto-merge github-token = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := autoMergeStep.Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-token.outputs.app-slug }}"; got != want {
|
||||
t.Fatalf("minted auto-merge App slug = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := autoMergeStep.Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want {
|
||||
t.Fatalf("configured auto-merge App slug = %q, want %q", got, want)
|
||||
}
|
||||
autoMergeScript := autoMergeStep.With["script"]
|
||||
for _, want := range []string{
|
||||
"github.rest.pulls.get",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"getReadyEventPull('auto-merge enable')",
|
||||
"process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim()",
|
||||
"process.env.REVIEWER_ROUTER_APP_SLUG?.trim()",
|
||||
"appSlug === 'github-actions'",
|
||||
"mintedAppSlug !== appSlug",
|
||||
"const expectedAppOwner = `${appSlug}[bot]`",
|
||||
"GET /repos/{owner}/{repo}/rules/branches/{branch}",
|
||||
".map(rule => Number(rule.ruleset_id))",
|
||||
"Number.isSafeInteger(rulesetID) && rulesetID > 0",
|
||||
"GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
|
||||
"ruleset.enforcement !== 'active'",
|
||||
"ruleset.target !== 'branch'",
|
||||
"ruleset.name === 'main-merge-writers'",
|
||||
"writerRuleset.source_type !== 'Repository'",
|
||||
"writerRuleset.source?.toLowerCase() !== repositorySource",
|
||||
"writerIncludes[0] !== 'refs/heads/main'",
|
||||
"writerExcludes.length !== 0",
|
||||
"writerRuleset.rules?.length !== 1",
|
||||
"function isStrictUpdateRule(rule)",
|
||||
"rule?.type !== 'update'",
|
||||
"!Object.prototype.hasOwnProperty.call(rule, 'parameters')",
|
||||
"parameters === null",
|
||||
"typeof parameters !== 'object'",
|
||||
"Array.isArray(parameters)",
|
||||
"const parameterKeys = Object.keys(parameters)",
|
||||
"parameterKeys.length === 1",
|
||||
"parameterKeys[0] === 'update_allows_fetch_and_merge'",
|
||||
"parameters.update_allows_fetch_and_merge === false",
|
||||
"!isStrictUpdateRule(writerRuleset.rules[0])",
|
||||
"typeof writerRuleset.node_id !== 'string'",
|
||||
"function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)",
|
||||
"graphRule.parameters.updateAllowsFetchAndMerge === false",
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"... on UpdateParameters",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
"current_user_can_bypass !== 'pull_requests_only'",
|
||||
"ruleset.current_user_can_bypass !== 'never'",
|
||||
"Reviewer Router App must never bypass main ruleset",
|
||||
"const skipWorkflowPattern =",
|
||||
"const safeCommitHeadline = `Merge pull request #${pullNumber}`",
|
||||
"const safeCommitBody =",
|
||||
"workflow-skip cleanup",
|
||||
"already has the reviewed ${expectedAppOwner} auto-merge request",
|
||||
"preserving it",
|
||||
"non-dedicated owner cleanup",
|
||||
"unsafe dedicated-App metadata cleanup",
|
||||
"getReadyEventPull('post-owner cleanup')",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"expectedHeadOid: $expectedHeadOid",
|
||||
"commitHeadline: $commitHeadline",
|
||||
"commitBody: $commitBody",
|
||||
"expectedHeadOid: eventHeadSha",
|
||||
"const exactRevision =",
|
||||
"enabledBy === expectedAppOwner",
|
||||
"disablePullRequestAutoMerge",
|
||||
"unsafe-merge-message cleanup",
|
||||
"enabledPull.auto_merge?.commit_title !== safeCommitHeadline",
|
||||
"enabledPull.auto_merge?.commit_message !== safeCommitBody",
|
||||
"if (revertedPull.auto_merge)",
|
||||
"enabledBy !== expectedAppOwner",
|
||||
} {
|
||||
if !strings.Contains(autoMergeScript, want) {
|
||||
t.Errorf("dedicated auto-merge script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(autoMergeScript, "const skipWorkflowPattern ="); got != 1 {
|
||||
t.Errorf("dedicated auto-merge script skip-workflow pattern declarations = %d, want exactly 1", got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"if (!exactRevision && enabledBy === expectedAppOwner)",
|
||||
"disableAutoMerge(enabledPull, 'stale-enable cleanup')",
|
||||
"existingOwner === expectedAppOwner &&",
|
||||
"disableAutoMerge(currentPull, 'workflow-skip cleanup')",
|
||||
"currentPull.auto_merge.commit_title === safeCommitHeadline",
|
||||
"currentPull.auto_merge.commit_message === safeCommitBody",
|
||||
"disableAutoMerge(enabledPull, 'unexpected owner cleanup')",
|
||||
} {
|
||||
if !strings.Contains(autoMergeScript, want) {
|
||||
t.Errorf("dedicated App stale-state cleanup is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"core.warning", "catch ("} {
|
||||
if strings.Contains(autoMergeScript, forbidden) {
|
||||
t.Errorf("dedicated auto-merge must fail closed instead of handling errors through %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"rule.ruleset_source_type",
|
||||
"rule.ruleset_source?.toLowerCase()",
|
||||
"ruleset.source_type !== 'Repository'",
|
||||
"ruleset.source?.toLowerCase() !== repositorySource",
|
||||
} {
|
||||
if strings.Contains(autoMergeScript, forbidden) {
|
||||
t.Errorf("dedicated App must inspect every applicable main ruleset, not prefilter through %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
reconcile, ok := workflow.Jobs["reconcile"]
|
||||
if !ok {
|
||||
t.Fatalf("workflow jobs = %v, want reconcile", workflow.Jobs)
|
||||
}
|
||||
if reconcile.If != "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' && (((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main') || github.event_name == 'workflow_run' || github.event_name == 'schedule')" {
|
||||
t.Fatalf("reconcile.if = %q, want trusted main/review/admission event guard", reconcile.If)
|
||||
}
|
||||
if len(reconcile.Steps) != 2 ||
|
||||
reconcile.Steps[0].ID != "reviewer-router-reconcile-token" ||
|
||||
reconcile.Steps[0].Uses != appTokenSHA ||
|
||||
reconcile.Steps[1].Uses != githubScriptSHA {
|
||||
t.Fatalf("reconcile steps = %#v, want scoped App token followed by pinned migration script", reconcile.Steps)
|
||||
}
|
||||
if reconcile.Steps[1].Name != "Reconcile and merge App-owned pull requests" {
|
||||
t.Fatalf("reconcile script step = %q, want explicit merge ownership", reconcile.Steps[1].Name)
|
||||
}
|
||||
if len(reconcile.Permissions) != 0 {
|
||||
t.Fatalf("reconcile built-in token permissions = %v, want none", reconcile.Permissions)
|
||||
}
|
||||
if len(reconcile.Steps[0].With) != 6 {
|
||||
t.Fatalf("reconcile token inputs = %v, want exactly the reviewed repository scope and two permissions", reconcile.Steps[0].With)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}",
|
||||
"private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}",
|
||||
"owner": "${{ github.repository_owner }}",
|
||||
"repositories": "${{ github.event.repository.name }}",
|
||||
"permission-contents": "write",
|
||||
"permission-pull-requests": "write",
|
||||
} {
|
||||
if got := reconcile.Steps[0].With[key]; got != want {
|
||||
t.Errorf("reconcile token input %q = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := reconcile.Steps[0].With["skip-token-revoke"]; ok {
|
||||
t.Error("reconcile App token must be revoked automatically after the job")
|
||||
}
|
||||
if got, want := reconcile.Steps[1].With["github-token"], "${{ steps.reviewer-router-reconcile-token.outputs.token }}"; got != want {
|
||||
t.Fatalf("reconcile github-token = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := reconcile.Steps[1].Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-reconcile-token.outputs.app-slug }}"; got != want {
|
||||
t.Fatalf("minted reconcile App slug = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := reconcile.Steps[1].Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want {
|
||||
t.Fatalf("configured reconcile App slug = %q, want %q", got, want)
|
||||
}
|
||||
reconcileScript := reconcile.Steps[1].With["script"]
|
||||
for _, want := range []string{
|
||||
"github.paginate(github.rest.pulls.list",
|
||||
"state: 'open'",
|
||||
"base: 'main'",
|
||||
"candidate.draft",
|
||||
"const candidateHasSafeAppRequest =",
|
||||
"const requestOwner =",
|
||||
"requestOwner === expectedAppOwner",
|
||||
"!candidate.auto_merge",
|
||||
"candidateHasSafeAppRequest",
|
||||
"currentPull.head.sha !== expectedHeadSha",
|
||||
"currentPull.base.sha !== expectedBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"const currentHasSafeAppRequest =",
|
||||
"currentOwner === expectedAppOwner",
|
||||
"currentHasSafeAppRequest",
|
||||
"async function disableAutoMerge(pullNumber, pullRequestId, phase)",
|
||||
"disablePullRequestAutoMerge",
|
||||
"if (revertedPull.auto_merge)",
|
||||
"return revertedPull",
|
||||
"replace non-dedicated auto-merge owner",
|
||||
"repair dedicated-App merge metadata",
|
||||
"enablePullRequestAutoMerge",
|
||||
"mergeMethod: MERGE",
|
||||
"const expectedAppOwner = `${appSlug}[bot]`",
|
||||
"GET /repos/{owner}/{repo}/rules/branches/{branch}",
|
||||
".map(rule => Number(rule.ruleset_id))",
|
||||
"Number.isSafeInteger(rulesetID) && rulesetID > 0",
|
||||
"GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
|
||||
"ruleset.enforcement !== 'active'",
|
||||
"ruleset.target !== 'branch'",
|
||||
"ruleset.name === 'main-merge-writers'",
|
||||
"function hasExactMainRulesetScope(ruleset)",
|
||||
"!hasExactMainRulesetScope(writerRuleset)",
|
||||
"writerIncludes[0] !== 'refs/heads/main'",
|
||||
"writerExcludes.length !== 0",
|
||||
"writerRuleset.rules?.length !== 1",
|
||||
"function isStrictUpdateRule(rule)",
|
||||
"rule?.type !== 'update'",
|
||||
"!Object.prototype.hasOwnProperty.call(rule, 'parameters')",
|
||||
"parameters === null",
|
||||
"typeof parameters !== 'object'",
|
||||
"Array.isArray(parameters)",
|
||||
"const parameterKeys = Object.keys(parameters)",
|
||||
"parameterKeys.length === 1",
|
||||
"parameterKeys[0] === 'update_allows_fetch_and_merge'",
|
||||
"parameters.update_allows_fetch_and_merge === false",
|
||||
"!isStrictUpdateRule(writerRuleset.rules[0])",
|
||||
"typeof writerRuleset.node_id !== 'string'",
|
||||
"function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)",
|
||||
"graphRule.parameters.updateAllowsFetchAndMerge === false",
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"... on UpdateParameters",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
"current_user_can_bypass !== 'pull_requests_only'",
|
||||
"ruleset.current_user_can_bypass !== 'never'",
|
||||
"Reviewer Router App must never bypass main ruleset",
|
||||
"const skipWorkflowPattern =",
|
||||
"const skipRequested =",
|
||||
"workflow-skip directive found in merge metadata; left manual-merge only",
|
||||
"const safeCommitHeadline = `Merge pull request #${candidate.number}`",
|
||||
"const safeCommitBody =",
|
||||
"expectedHeadOid: $expectedHeadOid",
|
||||
"commitHeadline: $commitHeadline",
|
||||
"commitBody: $commitBody",
|
||||
"expectedHeadOid: expectedHeadSha",
|
||||
"enabledPull.auto_merge?.commit_title !== safeCommitHeadline",
|
||||
"enabledPull.auto_merge?.commit_message !== safeCommitBody",
|
||||
"const failures = []",
|
||||
"catch (error)",
|
||||
"failures.push(failure)",
|
||||
"core.setFailed",
|
||||
"revisionChanged && enabledBy === expectedAppOwner",
|
||||
"enabledBy !== expectedAppOwner",
|
||||
"unexpected owner cleanup",
|
||||
"unsafe merge-message cleanup",
|
||||
"const mergePulls = await github.paginate(github.rest.pulls.list",
|
||||
"const candidateHasSafeAppRequest =",
|
||||
"currentPull.head.sha !== expectedHeadSha",
|
||||
"currentPull.base.sha !== expectedBaseSha",
|
||||
"currentPull.base.repo?.full_name?.toLowerCase() !== repositorySource",
|
||||
"!currentHasSafeAppRequest",
|
||||
"function hasSafeAppMergeIntent(",
|
||||
"const {data: finalPull} = await github.rest.pulls.get",
|
||||
"finalPull.head.sha !== expectedHeadSha",
|
||||
"finalPull.base.sha !== expectedBaseSha",
|
||||
"finalPull.base.repo?.full_name?.toLowerCase() !== repositorySource",
|
||||
"!hasSafeAppMergeIntent(",
|
||||
"changed at final synchronous merge preflight",
|
||||
"github.rest.pulls.merge",
|
||||
"sha: expectedHeadSha",
|
||||
"merge_method: 'merge'",
|
||||
"commit_title: safeCommitHeadline",
|
||||
"commit_message: safeCommitBody",
|
||||
"function requireSuccessfulMergeResponse(mergeResult)",
|
||||
"mergeResult?.merged !== true",
|
||||
"GitHub returned a successful response without merging",
|
||||
"typeof mergeResult.sha !== 'string'",
|
||||
"GitHub returned a successful merge without a merge commit SHA",
|
||||
"responseMergeSha = requireSuccessfulMergeResponse(mergeResult)",
|
||||
"![404, 405, 409].includes(status)",
|
||||
"function classifyMergeAttemptRecovery(",
|
||||
"return {outcome: 'merged', finalState}",
|
||||
"return {outcome: 'closed'}",
|
||||
"return {outcome: 'not_ready'}",
|
||||
"function validateAppMergeFinalState(",
|
||||
"pullRequest.state !== 'closed'",
|
||||
"pullRequest.head.sha !== expectedHeadSha",
|
||||
"pullRequest.base?.ref !== 'main'",
|
||||
"baseRepository !== expectedBaseRepository",
|
||||
"mergedBy !== expectedAppLogin",
|
||||
"mergeCommitSha === expectedMergeSha",
|
||||
"merge result was not attributed exactly",
|
||||
"if (currentPull.merged_at)",
|
||||
"closed without merging before synchronous merge",
|
||||
"if (latestPull.merged_at)",
|
||||
"latestPull.state !== 'open'",
|
||||
"status === 404",
|
||||
"const {data: mergedPull} = await github.rest.pulls.get",
|
||||
"const finalState = validateAppMergeFinalState(",
|
||||
"expectedAppOwner,",
|
||||
"responseMergeSha,",
|
||||
"responseMergeSha === undefined",
|
||||
"merged=${merged}",
|
||||
"not_ready=${notReady}",
|
||||
"Reviewer Router reconciliation had",
|
||||
} {
|
||||
if !strings.Contains(reconcileScript, want) {
|
||||
t.Errorf("reconciliation script is missing contract marker %q", want)
|
||||
}
|
||||
}
|
||||
if disableIndex, enableIndex := strings.Index(reconcileScript, "disablePullRequestAutoMerge"), strings.Index(reconcileScript, "enablePullRequestAutoMerge"); disableIndex < 0 || enableIndex <= disableIndex {
|
||||
t.Error("legacy reconciliation must disable the exact unsafe owner before App-token enable")
|
||||
}
|
||||
if got := strings.Count(reconcileScript, "const skipWorkflowPattern ="); got != 1 {
|
||||
t.Errorf("reconciliation script skip-workflow pattern declarations = %d, want exactly 1", got)
|
||||
}
|
||||
if got := strings.Count(reconcileScript, "await assertReviewerRouterRulesetBoundary();"); got != 2 {
|
||||
t.Errorf("ruleset boundary validations = %d, want initial validation plus per-merge revalidation", got)
|
||||
}
|
||||
validationIndex := strings.LastIndex(reconcileScript, "await assertReviewerRouterRulesetBoundary();")
|
||||
finalIntentIndex := strings.Index(reconcileScript, "const {data: finalPull} = await github.rest.pulls.get")
|
||||
mergeIndex := strings.Index(reconcileScript, "github.rest.pulls.merge")
|
||||
if validationIndex < 0 || finalIntentIndex <= validationIndex || mergeIndex <= finalIntentIndex {
|
||||
t.Error("synchronous merge must revalidate the ruleset boundary and then the final App intent immediately before merge")
|
||||
}
|
||||
for _, forbidden := range []string{"--admin", "admin: true", "bypass:"} {
|
||||
if strings.Contains(reconcileScript, forbidden) {
|
||||
t.Errorf("synchronous App merge must not request an explicit override through %q", forbidden)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"context.payload.workflow_run",
|
||||
"context.payload.pull_request",
|
||||
"github.event.workflow_run.pull_requests",
|
||||
"listWorkflowRunArtifacts",
|
||||
"downloadArtifact",
|
||||
"actions/download-artifact",
|
||||
"workflow_run.head_repository",
|
||||
} {
|
||||
if strings.Contains(reconcileScript, forbidden) {
|
||||
t.Errorf("privileged workflow_run reconciliation must treat the event only as a wake-up signal, found %q", forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reconcileScript, "const unsafeOwner") {
|
||||
t.Error("reconciliation must converge every non-App owner, not only the legacy built-in owner")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"rule.ruleset_source_type",
|
||||
"rule.ruleset_source?.toLowerCase()",
|
||||
"ruleset.source_type !== 'Repository'",
|
||||
"ruleset.source?.toLowerCase() !== repositorySource",
|
||||
} {
|
||||
if strings.Contains(reconcileScript, forbidden) {
|
||||
t.Errorf("reconciliation App must inspect every applicable main ruleset, not prefilter through %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
for _, forbidden := range []string{
|
||||
"['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']",
|
||||
"PeterGuy326",
|
||||
"core.setFailed",
|
||||
"secrets.HOMEBREW_PR_TOKEN",
|
||||
"secrets.RELEASE_GOVERNANCE_TOKEN",
|
||||
"secrets.GITHUB_TOKEN",
|
||||
} {
|
||||
if strings.Contains(string(data), forbidden) {
|
||||
t.Errorf("reviewer router must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewerRouterApprovalSignalIsUnprivileged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "reviewer-router-approval-signal.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(reviewer-router-approval-signal.yml) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
|
||||
var workflow reviewerRouterWorkflow
|
||||
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
||||
t.Fatalf("yaml.Unmarshal(%s) error = %v", path, err)
|
||||
}
|
||||
if len(workflow.On) != 1 {
|
||||
t.Fatalf("approval signal triggers = %v, want pull_request_review only", workflow.On)
|
||||
}
|
||||
review, ok := workflow.On["pull_request_review"]
|
||||
if !ok ||
|
||||
len(review.Branches) != 0 ||
|
||||
!reflect.DeepEqual(review.Types, []string{"submitted", "dismissed"}) {
|
||||
t.Fatalf("approval signal trigger = %v, want submitted/dismissed reviews without an unsupported branch filter", review)
|
||||
}
|
||||
if len(workflow.Permissions) != 0 {
|
||||
t.Fatalf("approval signal permissions = %v, want none", workflow.Permissions)
|
||||
}
|
||||
if len(workflow.Jobs) != 1 {
|
||||
t.Fatalf("approval signal jobs = %v, want one signal job", workflow.Jobs)
|
||||
}
|
||||
job, ok := workflow.Jobs["signal"]
|
||||
if !ok || len(job.Permissions) != 0 || len(job.Steps) != 1 {
|
||||
t.Fatalf("approval signal job = %#v, want one zero-permission step", job)
|
||||
}
|
||||
step := job.Steps[0]
|
||||
if step.Uses != "" || step.Run != "echo \"Review state changed; default-branch reconciliation will re-evaluate App-owned merge intents.\"" {
|
||||
t.Fatalf("approval signal step = %#v, want shell-only notification", step)
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"secrets.",
|
||||
"actions/checkout",
|
||||
"github-token",
|
||||
"contents: write",
|
||||
"pull-requests: write",
|
||||
} {
|
||||
if strings.Contains(string(data), forbidden) {
|
||||
t.Errorf("approval signal must not contain %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ci.yml) error = %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]") {
|
||||
t.Error("CI must rerun admission when a draft becomes ready or merge metadata changes")
|
||||
}
|
||||
start := strings.Index(workflow, "\n test:\n")
|
||||
end := strings.Index(workflow, "\n test-darwin:\n")
|
||||
if start < 0 || end <= start {
|
||||
t.Fatal("CI workflow missing Test aggregate boundaries")
|
||||
}
|
||||
testJob := workflow[start:end]
|
||||
for _, want := range []string{
|
||||
"contents: read",
|
||||
"pull-requests: read",
|
||||
"name: Verify auto-merge identity",
|
||||
"if: github.event_name == 'pull_request' && github.event.pull_request.draft == false",
|
||||
"REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}",
|
||||
"const configuredAppSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim()",
|
||||
"const reviewedForkAppSlug = 'dingtalk-dws-reviewer-router'",
|
||||
"context.payload.pull_request.head.repo.full_name?.toLowerCase()",
|
||||
"const baseRepository = `${owner}/${repo}`.toLowerCase()",
|
||||
"Boolean(pullHeadRepository) && pullHeadRepository !== baseRepository",
|
||||
"configuredAppSlug || (isForkPull ? reviewedForkAppSlug : '')",
|
||||
"Fork pull request cannot read the repository App slug variable; using the reviewed public slug",
|
||||
"const expectedAppOwner = `${appSlug}[bot]`",
|
||||
"github.rest.pulls.get",
|
||||
"github.rest.repos.get",
|
||||
"function classifyMergeDefaults(repository)",
|
||||
"mergeDefaultsProjection === 'invalid'",
|
||||
"mergeDefaultsProjection === 'omitted'",
|
||||
"exact validation is delegated to the dedicated App",
|
||||
"const writerRulesetName = 'main-merge-writers'",
|
||||
"GET /repos/{owner}/{repo}/rules/branches/{branch}",
|
||||
"rule.ruleset_source_type === 'Repository'",
|
||||
"rule.ruleset_source?.toLowerCase() === repositorySource",
|
||||
"GET /repos/{owner}/{repo}/rulesets/{ruleset_id}",
|
||||
"ruleset.enforcement !== 'active'",
|
||||
"ruleset.target !== 'branch'",
|
||||
"ruleset.source_type !== 'Repository'",
|
||||
"ruleset.source?.toLowerCase() !== repositorySource",
|
||||
"writerIncludes[0] !== 'refs/heads/main'",
|
||||
"writerExcludes.length !== 0",
|
||||
"writerRuleset.rules?.length !== 1",
|
||||
"function isStrictUpdateRule(rule)",
|
||||
"rule?.type !== 'update'",
|
||||
"!Object.prototype.hasOwnProperty.call(rule, 'parameters')",
|
||||
"parameters === null",
|
||||
"typeof parameters !== 'object'",
|
||||
"Array.isArray(parameters)",
|
||||
"const parameterKeys = Object.keys(parameters)",
|
||||
"parameterKeys.length === 1",
|
||||
"parameterKeys[0] === 'update_allows_fetch_and_merge'",
|
||||
"parameters.update_allows_fetch_and_merge === false",
|
||||
"!isStrictUpdateRule(writerRuleset.rules[0])",
|
||||
"typeof writerRuleset.node_id !== 'string'",
|
||||
"function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)",
|
||||
"graphRule.parameters.updateAllowsFetchAndMerge === false",
|
||||
"query ReviewerRouterWriterRule($rulesetID: ID!)",
|
||||
"... on UpdateParameters",
|
||||
"{rulesetID: writerRuleset.node_id}",
|
||||
"!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)",
|
||||
"writerRuleset.current_user_can_bypass !== 'never'",
|
||||
"deny this built-in Actions identity any bypass",
|
||||
"const skipWorkflowPattern =",
|
||||
"currentPull.title",
|
||||
"currentPull.auto_merge?.commit_title",
|
||||
"currentPull.auto_merge?.commit_message",
|
||||
"merge metadata contains a GitHub workflow-skip directive",
|
||||
"currentPull.head.sha !== eventHeadSha",
|
||||
"currentPull.base.sha !== eventBaseSha",
|
||||
"currentPull.state !== 'open'",
|
||||
"currentPull.draft",
|
||||
"currentPull.base.ref !== 'main'",
|
||||
"currentPull.auto_merge",
|
||||
"enabled_by?.login",
|
||||
"enabledBy === expectedAppOwner",
|
||||
"currentPull.auto_merge.commit_title === safeCommitHeadline",
|
||||
"currentPull.auto_merge.commit_message === safeCommitBody",
|
||||
"must be null or owned by ${expectedAppOwner}",
|
||||
"const maxAttempts = 6",
|
||||
"setTimeout(resolve, 5000)",
|
||||
"core.setFailed",
|
||||
} {
|
||||
if !strings.Contains(testJob, want) {
|
||||
t.Errorf("Test aggregate missing auto-merge identity contract %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(testJob, "Null and non-built-in merge identities emit either the protected-main") ||
|
||||
!strings.Contains(testJob, "main-merge-writers never lets it update main") {
|
||||
t.Error("auto-merge identity comment must name the push/repair paths and built-in writer self-check")
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"REVIEWER_ROUTER_APP_PRIVATE_KEY",
|
||||
"HOMEBREW_PR_TOKEN",
|
||||
"RELEASE_GOVERNANCE_TOKEN",
|
||||
"can emit protected-main workflow events",
|
||||
"pull-requests: write",
|
||||
"contents: write",
|
||||
"actions: write",
|
||||
} {
|
||||
if strings.Contains(testJob, forbidden) {
|
||||
t.Errorf("read-only admission identity check must not consume %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user