Merge remote-tracking branch 'upstream/main' into fix/report-entry-submit-require-recipient
# Conflicts: # scripts/policy/interface-migrations/approved-flag-migrations-v1.json
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
|
||||
@@ -0,0 +1,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,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Removed
|
||||
---
|
||||
|
||||
- **Education and college vendor extensions removed** — removes `dws edu-contact`, `dws edu-group`, `dws edu-app`, `dws edu-familygroup`, and `dws college-contact` from the CLI, Schema, bundled Skills, and open-edition MCP endpoint registry. Future DWS packages no longer expose these five command surfaces.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: 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'
|
||||
|
||||
+460
-4
@@ -5,12 +5,16 @@ 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 }}
|
||||
# Only duplicate runs for the exact PR base/head revision cancel each other.
|
||||
# A later revision must not kill an earlier cold-cache producer, and every
|
||||
# protected-main SHA keeps an independent producer run.
|
||||
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -837,7 +841,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 +922,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 +1542,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 +1677,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 +1822,7 @@ jobs:
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-main-metadata
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
@@ -1405,6 +1839,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 +1848,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 +1871,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 +2012,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
@@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with
|
||||
## Command framework declaration
|
||||
|
||||
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
|
||||
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves
|
||||
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
|
||||
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
|
||||
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
|
||||
@@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with
|
||||
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
|
||||
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
|
||||
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
|
||||
- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface.
|
||||
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
|
||||
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
|
||||
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
|
||||
|
||||
@@ -6,6 +6,86 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.2] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.60-beta.1"
|
||||
version "1.0.60-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
|
||||
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
|
||||
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
+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),
|
||||
|
||||
+186
-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,75 @@ 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. CI concurrency is keyed by PR number plus exact
|
||||
event base/head SHA. Duplicate runs for that exact revision may cancel each
|
||||
other, but a later revision cannot kill an earlier cold-cache producer. Main
|
||||
runs use the 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 +413,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 +434,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.
|
||||
|
||||
@@ -274,6 +274,7 @@ Definition(仅声明;不可编译)
|
||||
| 层 | 含义 | 今日落点 |
|
||||
|---|---|---|
|
||||
| **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) |
|
||||
| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 |
|
||||
| **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) |
|
||||
| **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) |
|
||||
| **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) |
|
||||
@@ -310,7 +311,25 @@ Definition(仅声明;不可编译)
|
||||
3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格;
|
||||
4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。
|
||||
|
||||
#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3)
|
||||
#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`)
|
||||
|
||||
`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`:
|
||||
|
||||
| 轴 | 允许值 | 语义 |
|
||||
|---|---|---|
|
||||
| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 |
|
||||
| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 |
|
||||
| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 |
|
||||
|
||||
硬规则:
|
||||
|
||||
1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。
|
||||
2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。
|
||||
3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。
|
||||
4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。
|
||||
5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。
|
||||
|
||||
#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3)
|
||||
|
||||
当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定):
|
||||
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
{
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"generated_at": "2026-08-24T20:14:49.172788",
|
||||
"count": 437,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+contract-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+obj-template-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-statistics-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-submit-detail",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+user-rules",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
@@ -2473,137 +2523,254 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+credentials-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+disable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+enable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-subscribe",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+permission-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-config",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-check-approval",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-config",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the
|
||||
// old helpers-only AST scan with an invariant over the two real assembly
|
||||
// products: the deterministic distribution tree and a runtime tree after a
|
||||
// nested plugin overlay has been merged.
|
||||
func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) {
|
||||
distribution := NewSchemaSourceRootCommand()
|
||||
for _, path := range []string{
|
||||
"sheet range read",
|
||||
"pat chmod",
|
||||
"plugin list",
|
||||
"chat +chat-messages",
|
||||
} {
|
||||
requireFinalCommandPath(t, distribution, path)
|
||||
}
|
||||
if err := cobracmd.ValidateGroupTree(distribution); err != nil {
|
||||
t.Fatalf("distribution command tree GroupPolicy invariant: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root)
|
||||
})
|
||||
runtime := NewRootCommand()
|
||||
requireFinalCommandPath(t, runtime, "conference camera open")
|
||||
if err := cobracmd.ValidateGroupTree(runtime); err != nil {
|
||||
t.Fatalf("runtime command tree GroupPolicy invariant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command {
|
||||
t.Helper()
|
||||
command, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || command == nil || len(remaining) != 0 || command == root {
|
||||
t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err)
|
||||
}
|
||||
return command
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
@@ -43,6 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
group.AddCommand(newMCPURLGetCommand(caller))
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
|
||||
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
|
||||
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
|
||||
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
@@ -237,52 +237,182 @@ 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{
|
||||
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
|
||||
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
|
||||
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
|
||||
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
|
||||
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
|
||||
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
|
||||
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
|
||||
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
|
||||
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
|
||||
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
|
||||
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
|
||||
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
|
||||
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
|
||||
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
|
||||
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
|
||||
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
|
||||
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
|
||||
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
|
||||
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
|
||||
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
|
||||
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
|
||||
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
|
||||
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
|
||||
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
|
||||
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
|
||||
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
|
||||
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
|
||||
"report +template-search": {"report", "+template-search", "--query", "fixture"},
|
||||
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
|
||||
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
|
||||
"agoal contract detail": {"agoal", "contract", "detail", "--contract-id", "contract-1"},
|
||||
"agoal contract update": {"agoal", "contract", "update", "--contract-id", "contract-1", "--dimensions", `[{"id":"dimension-1","title":"Fixture Dimension","weight":100,"objectives":[]}]`},
|
||||
"agoal obj-template create-or-update": {"agoal", "obj-template", "create-or-update", "--template-id", "template-1", "--dimensions", `[{"title":"Fixture Dimension","weight":100}]`},
|
||||
"agoal obj-template list": {"agoal", "obj-template", "list", "--keyword", "fixture", "--page", "2", "--page-size", "7"},
|
||||
"agoal report list-statistics": {"agoal", "report", "list-statistics", "--keyword", "Fixture Rule"},
|
||||
"agoal report submit-detail": {"agoal", "report", "submit-detail", "--template-id", "template-1", "--submit-state", "ON_TIME", "--query-date", "2026-06-18T00:00:00+08:00"},
|
||||
"agoal scorecard detail": {"agoal", "scorecard", "detail", "--dept-id", "dept-1", "--selected-time", "2026-01-01T00:00:00+08:00"},
|
||||
"agoal scorecard entity-detail": {"agoal", "scorecard", "entity-detail", "--sc-id", "scorecard-1", "--entity-id", "entity-1"},
|
||||
"agoal strategy detail": {"agoal", "strategy", "detail", "--profile-id", "profile-1"},
|
||||
"agoal user objectives": {"agoal", "user", "objectives", "--user-id", "user-1", "--rule-id", "rule-1", "--period-ids", "period-1,period-2"},
|
||||
"agoal user rules": {"agoal", "user", "rules", "--user-id", "user-1"},
|
||||
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
|
||||
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
|
||||
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
|
||||
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
|
||||
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
|
||||
"audit export": {"audit", "export", "--since", "2026-03-01", "--until", "2026-03-10", "--format", "jsonl", "--output", "/tmp/dws-audit-export-fixture.jsonl"},
|
||||
"audit tail": {"audit", "tail", "--lines", "7", "--output", "/tmp/dws-audit-tail-fixture.jsonl"},
|
||||
"audit verify": {"audit", "verify", "--file", "../../go.mod", "--output", "/tmp/dws-audit-verify-fixture.json"},
|
||||
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
|
||||
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
|
||||
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
|
||||
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
|
||||
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
|
||||
"contact +org": {"contact", "+org", "--name", "Fixture User"},
|
||||
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
|
||||
"contact +team": {"contact", "+team", "--name", "Fixture User"},
|
||||
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
|
||||
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
|
||||
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
|
||||
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
|
||||
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
|
||||
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
|
||||
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
|
||||
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
|
||||
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
|
||||
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
|
||||
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
|
||||
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
|
||||
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
|
||||
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
|
||||
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
|
||||
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
|
||||
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
|
||||
"dev app create": {"dev", "app", "create", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"dev app credentials get": {"dev", "app", "credentials", "get", "--unified-app-id", "app-1"},
|
||||
"dev app delete": {"dev", "app", "delete", "--unified-app-id", "app-1", "--confirm-name", "Fixture App", "--yes"},
|
||||
"dev app disable": {"dev", "app", "disable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app enable": {"dev", "app", "enable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app event list": {"dev", "app", "event", "list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"dev app event subscribe": {"dev", "app", "event", "subscribe", "--unified-app-id", "app-1", "--event-codes", "chat_message_received", "--yes"},
|
||||
"dev app event unsubscribe": {"dev", "app", "event", "unsubscribe", "--unified-app-id", "app-1", "--event-codes", "chat_message_received", "--yes"},
|
||||
"dev app list": {"dev", "app", "list", "--robot-name", "Fixture Robot"},
|
||||
"dev app member add": {"dev", "app", "member", "add", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"dev app member list": {"dev", "app", "member", "list", "--unified-app-id", "app-1"},
|
||||
"dev app member remove": {"dev", "app", "member", "remove", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"dev app permission add": {"dev", "app", "permission", "add", "--unified-app-id", "app-1", "--scope-values", "Contact.User.Read", "--yes"},
|
||||
"dev app permission remove": {"dev", "app", "permission", "remove", "--unified-app-id", "app-1", "--scope-values", "Contact.User.Read", "--yes"},
|
||||
"dev app robot config": {"dev", "app", "robot", "config", "--unified-app-id", "app-1", "--i18n-description", `{"zh_CN":"Fixture Robot"}`, "--yes"},
|
||||
"dev app robot disable": {"dev", "app", "robot", "disable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app robot enable": {"dev", "app", "robot", "enable", "--unified-app-id", "app-1", "--yes"},
|
||||
"dev app robot get": {"dev", "app", "robot", "get", "--unified-app-id", "app-1"},
|
||||
"dev app robot result": {"dev", "app", "robot", "result", "--task-id", "task-1"},
|
||||
"dev app robot submit": {"dev", "app", "robot", "submit", "--name", "Fixture Agent", "--desc", "Fixture robot description", "--robot-name", "Fixture Robot", "--yes"},
|
||||
"dev app security config": {"dev", "app", "security", "config", "--unified-app-id", "app-1", "--redirect-urls", "https://example.test/callback", "--yes"},
|
||||
"dev app update": {"dev", "app", "update", "--unified-app-id", "app-1", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"dev app version check-approval": {"dev", "app", "version", "check-approval", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"dev app version create": {"dev", "app", "version", "create", "--unified-app-id", "app-1", "--version", "1.0.1", "--desc", "Fixture Version", "--yes"},
|
||||
"dev app version get": {"dev", "app", "version", "get", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"dev app version list": {"dev", "app", "version", "list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"dev app version publish": {"dev", "app", "version", "publish", "--unified-app-id", "app-1", "--version-id", "version-1", "--yes"},
|
||||
"dev app version status": {"dev", "app", "version", "status", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"dev app webapp config": {"dev", "app", "webapp", "config", "--unified-app-id", "app-1", "--pc-homepage-url", "https://example.test/app", "--yes"},
|
||||
"dev app webapp get": {"dev", "app", "webapp", "get", "--unified-app-id", "app-1"},
|
||||
"dev connect restart": {"dev", "connect", "restart", "--robot-client-id", "robot-client-1"},
|
||||
"dev connect status": {"dev", "connect", "status", "--robot-client-id", "robot-client-1"},
|
||||
"dev connect stop": {"dev", "connect", "stop", "--robot-client-id", "robot-client-1"},
|
||||
"dev doc search": {"dev", "doc", "search", "--query", "fixture", "--page", "2"},
|
||||
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"devapp +create": {"devapp", "+create", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"devapp +delete": {"devapp", "+delete", "--unified-app-id", "app-1", "--yes"},
|
||||
"devapp +disable": {"devapp", "+disable", "--unified-app-id", "app-1", "--yes"},
|
||||
"devapp +enable": {"devapp", "+enable", "--unified-app-id", "app-1", "--yes"},
|
||||
"devapp +event-list": {"devapp", "+event-list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"devapp +get": {"devapp", "+get", "--unified-app-id", "app-1"},
|
||||
"devapp +list": {"devapp", "+list", "--app-key", "app-key-1"},
|
||||
"devapp +member-add": {"devapp", "+member-add", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"devapp +member-list": {"devapp", "+member-list", "--unified-app-id", "app-1", "--user-id", "user-1"},
|
||||
"devapp +member-remove": {"devapp", "+member-remove", "--unified-app-id", "app-1", "--member-type", "DEVELOPER", "--user-ids", "user-1,user-2", "--yes"},
|
||||
"devapp +permission-list": {"devapp", "+permission-list", "--unified-app-id", "app-1", "--api-status", "PUBLISHED", "--scope-type", "APP"},
|
||||
"devapp +robot-get": {"devapp", "+robot-get", "--unified-app-id", "app-1"},
|
||||
"devapp +update": {"devapp", "+update", "--unified-app-id", "app-1", "--name", "Fixture App", "--desc", "Fixture Description", "--yes"},
|
||||
"devapp +version-check-approval": {"devapp", "+version-check-approval", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"devapp +version-get": {"devapp", "+version-get", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"devapp +version-list": {"devapp", "+version-list", "--unified-app-id", "app-1", "--cursor", "cursor-1"},
|
||||
"devapp +version-status": {"devapp", "+version-status", "--unified-app-id", "app-1", "--version-id", "version-1"},
|
||||
"devapp +webapp-config": {"devapp", "+webapp-config", "--unified-app-id", "app-1", "--pc-homepage-url", "https://example.test/app", "--yes"},
|
||||
"devapp +webapp-get": {"devapp", "+webapp-get", "--unified-app-id", "app-1"},
|
||||
"event +listen-im": {"event", "+listen-im", "--user", "user-1", "--events", "message,reaction", "--query", "fixture", "--duration", "1s", "--max-events", "1"},
|
||||
"event consume": {"event", "consume", "--subscribe-id", "subscription-1", "--user", "user-1", "--group", "fixture-conversation", "--query", "fixture", "--output-dir", "/tmp/dws-event-fixture", "--filter-json", `{"rules":[]}`},
|
||||
"event list": {"event", "list", "--category", "im", "--include-pending"},
|
||||
"event schema": {"event", "schema", "--flatten"},
|
||||
"event status": {"event", "status", "--event", "im_message_received", "--status", "active", "--subscribe-id", "subscription-1"},
|
||||
"event stop": {"event", "stop", "--all", "--yes"},
|
||||
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
|
||||
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
|
||||
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
|
||||
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
|
||||
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
|
||||
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
|
||||
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
|
||||
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
|
||||
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
|
||||
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
|
||||
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
|
||||
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
|
||||
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
|
||||
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
|
||||
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
|
||||
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
|
||||
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
|
||||
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
|
||||
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
|
||||
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
|
||||
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
|
||||
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
|
||||
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
|
||||
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
|
||||
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
|
||||
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
|
||||
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
|
||||
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
|
||||
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
|
||||
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
|
||||
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
|
||||
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
|
||||
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
|
||||
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
|
||||
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
|
||||
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
|
||||
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
|
||||
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
|
||||
"report +template-search": {"report", "+template-search", "--query", "fixture"},
|
||||
"recruit job create": {"recruit", "job", "create", "--from", "testdata/recruit_job.json", "--yes"},
|
||||
"recruit job get": {"recruit", "job", "get", "--job-id", "job-1"},
|
||||
"recruit job list": {"recruit", "job", "list", "--job-ids", "job-1,job-2", "--creator-user-ids", "user-1,user-2", "--keyword", "fixture", "--cursor", "cursor-1", "--size", "7"},
|
||||
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
|
||||
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
|
||||
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
@@ -343,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"},
|
||||
},
|
||||
@@ -1030,14 +1172,14 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// exercises every distinct reviewed mutating complete-command template in the
|
||||
// eight-product Shortcut expansion. The fixture gate already proves every
|
||||
// reviewed product expansions. The fixture gate already proves every
|
||||
// alias resolves through PreParse; this gate removes the confirmation flag
|
||||
// from one active alias invocation per distinct template and requires the
|
||||
// runtime boundary to stop it before the first transport call. An explicit
|
||||
// --dry-run is a reviewed preview path and must not carry a bypass flag.
|
||||
func TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -1051,7 +1193,9 @@ func TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfi
|
||||
}
|
||||
product, _, _ := strings.Cut(fixture.Command, " ")
|
||||
switch product {
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown":
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat",
|
||||
"agoal", "audit", "dev", "devapp", "event", "mcp", "recruit":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
@@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) {
|
||||
}
|
||||
for _, child := range append([]*cobra.Command(nil), parent.Commands()...) {
|
||||
pruneEmptyPluginGroups(child)
|
||||
if cmdutil.IsGroup(child) && len(child.Commands()) == 0 {
|
||||
_, group, err := corecmd.GroupPolicyFor(child)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err))
|
||||
}
|
||||
if group && len(child.Commands()) == 0 {
|
||||
parent.RemoveCommand(child)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" }
|
||||
func (pluginWrongFlagValue) Set(string) error { return nil }
|
||||
func (pluginWrongFlagValue) Type() string { return "wrong" }
|
||||
|
||||
func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) {
|
||||
emptyParent := &cobra.Command{Use: "plugin"}
|
||||
emptyGroup := &cobra.Command{Use: "empty"}
|
||||
corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
emptyParent.AddCommand(emptyGroup)
|
||||
pruneEmptyPluginGroups(emptyParent)
|
||||
if len(emptyParent.Commands()) != 0 {
|
||||
t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands())
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "plugin"}
|
||||
child := &cobra.Command{Use: "group"}
|
||||
corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
for key := range child.Annotations {
|
||||
child.Annotations[key] = "malformed"
|
||||
}
|
||||
parent.AddCommand(child)
|
||||
|
||||
defer func() {
|
||||
got := recover()
|
||||
message, ok := got.(string)
|
||||
if !ok || !strings.Contains(message, "prune plugin group") {
|
||||
t.Fatalf("pruneEmptyPluginGroups panic = %v", got)
|
||||
}
|
||||
}()
|
||||
pruneEmptyPluginGroups(parent)
|
||||
}
|
||||
|
||||
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
|
||||
invalidRoot := conferencePluginDescriptor()
|
||||
invalidRoot.CLI.Command = "Invalid Root"
|
||||
@@ -507,7 +544,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
|
||||
mergePluginRoot(nil, root)
|
||||
mergePluginRoot(root, nil)
|
||||
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
|
||||
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
|
||||
source := cobracmd.NewGroupCommand("plugin", "plugin")
|
||||
source.Aliases = []string{"one", "two"}
|
||||
source.AddCommand(&cobra.Command{Use: "leaf"})
|
||||
mergePluginRoot(destination, source)
|
||||
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{
|
||||
"aisearch", "--query", "Alice", "--yess", "enterprise",
|
||||
"--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help",
|
||||
}
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws aisearch enterprise" ||
|
||||
!slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") {
|
||||
t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx)
|
||||
}
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("corrected group-middle persistent flag failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"aisearch", "--types", "enterprise"},
|
||||
{"aisearch", "--types", "false", "enterprise"},
|
||||
{"aisearch", "--types=false", "enterprise"},
|
||||
} {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) {
|
||||
t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
@@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
|
||||
args []string
|
||||
wantReason string
|
||||
wantCommand string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
@@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws dev app",
|
||||
},
|
||||
{
|
||||
name: "unknown aisearch subcommand before protected flag",
|
||||
args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"},
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws aisearch",
|
||||
wantHint: "dws aisearch enterprise",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
|
||||
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) {
|
||||
t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
|
||||
t.Fatalf("command error leaked flag classification: %#v", structured)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" ||
|
||||
structured.Message != `unknown subcommand "switch" for "dws auth"` ||
|
||||
structured.Hint != "Run 'dws auth --help' for the full list" {
|
||||
t.Fatalf("error = %#v, want bounded auth subcommand guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRetiredEduVendorExtensionsAreAbsentFromRuntimeAndSchema(t *testing.T) {
|
||||
products := []string{
|
||||
"college-contact",
|
||||
"edu-app",
|
||||
"edu-contact",
|
||||
"edu-familygroup",
|
||||
"edu-group",
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, product := range products {
|
||||
for _, command := range root.Commands() {
|
||||
if command.Name() == product {
|
||||
t.Fatalf("retired product command %q remains mounted", product)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retiredProducts := make(map[string]bool, len(products))
|
||||
for _, product := range products {
|
||||
retiredProducts[product] = true
|
||||
}
|
||||
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
for _, product := range snapshot.Catalog["products"].([]map[string]any) {
|
||||
productID, _ := product["id"].(string)
|
||||
if retiredProducts[productID] {
|
||||
t.Errorf("retired product %q remains in the Schema catalog", productID)
|
||||
}
|
||||
}
|
||||
for canonicalPath := range snapshot.Tools {
|
||||
for product := range retiredProducts {
|
||||
if canonicalPath == product || strings.HasPrefix(canonicalPath, product+".") {
|
||||
t.Errorf("retired Schema tool %q remains under product %q", canonicalPath, product)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-10
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
@@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
|
||||
return nil
|
||||
},
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
|
||||
bindPersistentFlags(root, flags)
|
||||
|
||||
@@ -935,25 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
|
||||
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
|
||||
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
|
||||
|
||||
navigationGroup := func(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return command
|
||||
}
|
||||
hybridGroup := func(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return command
|
||||
}
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(patCaller),
|
||||
newProfileCommand(),
|
||||
navigationGroup(newAuthCommand(patCaller)),
|
||||
navigationGroup(newProfileCommand()),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
navigationGroup(newSkillCommand()),
|
||||
hybridGroup(newCacheCommand()),
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
navigationGroup(newConfigCommand()),
|
||||
newDoctorCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(flags),
|
||||
newAuditCommand(),
|
||||
hybridGroup(newRecoveryCommand()),
|
||||
navigationGroup(newEventCommand(flags)),
|
||||
navigationGroup(newAuditCommand()),
|
||||
newCompletionCommand(root),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
usage.NewShortcutCommand(),
|
||||
navigationGroup(usage.NewShortcutCommand()),
|
||||
schemaCmd,
|
||||
mcpCmd,
|
||||
navigationGroup(mcpCmd),
|
||||
}
|
||||
root.AddCommand(utilityCommands...)
|
||||
|
||||
|
||||
@@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(tc.args)
|
||||
err := command.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
|
||||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
|
||||
t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"sheet.create_float_image",
|
||||
"sheet.update_float_image",
|
||||
)
|
||||
|
||||
create := payload.Tools["sheet.create_float_image"]
|
||||
if create == nil {
|
||||
t.Fatal("missing sheet.create_float_image")
|
||||
}
|
||||
if create["interface_mode"] != "mcp" {
|
||||
t.Fatalf("create interface mode = %#v", create["interface_mode"])
|
||||
}
|
||||
createRef, _ := create["interface_ref"].(map[string]any)
|
||||
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
|
||||
t.Fatalf("create interface ref = %#v", createRef)
|
||||
}
|
||||
createDryRun, _ := create["dry_run"].(map[string]any)
|
||||
if createDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("create dry-run = %#v", createDryRun)
|
||||
}
|
||||
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
|
||||
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
|
||||
}
|
||||
createParameters, _ := create["parameters"].(map[string]any)
|
||||
file, _ := createParameters["file"].(map[string]any)
|
||||
src, _ := createParameters["src"].(map[string]any)
|
||||
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
|
||||
t.Fatalf("create --file metadata = %#v", file)
|
||||
}
|
||||
if schemaContractString(file["property"]) != "" {
|
||||
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
|
||||
}
|
||||
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
|
||||
t.Fatalf("create --src compatibility metadata = %#v", src)
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
|
||||
|
||||
update := payload.Tools["sheet.update_float_image"]
|
||||
if update == nil {
|
||||
t.Fatal("missing sheet.update_float_image")
|
||||
}
|
||||
updateDryRun, _ := update["dry_run"].(map[string]any)
|
||||
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
|
||||
}
|
||||
updateParameters, _ := update["parameters"].(map[string]any)
|
||||
updateFile, _ := updateParameters["file"].(map[string]any)
|
||||
if schemaContractString(updateFile["property"]) != "" {
|
||||
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil || command.Flags().Lookup("file") == nil {
|
||||
t.Fatalf("%s has no executable --file flag", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 425
|
||||
publicShortcutCount = 436
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 482
|
||||
schemaPublishedShortcutCount = 493
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
publiclyDeliveredShortcutCount = 436
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
|
||||
@@ -20,18 +20,50 @@ import (
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
operation string
|
||||
retryable *bool
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
combined := text + " " + detail
|
||||
|
||||
if code == "999" &&
|
||||
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
|
||||
classified := serverFailureClass{
|
||||
message: message,
|
||||
reason: "upstream_internal_error",
|
||||
origin: "dingtalk_api",
|
||||
stage: "upstream_execution",
|
||||
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
|
||||
actions: []string{
|
||||
"检查目标资源的当前状态,确认本次操作是否已经生效",
|
||||
"状态未确认前不要直接重试写操作",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
|
||||
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
|
||||
retryable := false
|
||||
classified.operation = "todo/create_personal_todo"
|
||||
classified.retryable = &retryable
|
||||
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
|
||||
classified.actions = []string{
|
||||
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
|
||||
"确认没有创建成功后再重新提交",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
}
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
tool string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
if classified.operation != "" {
|
||||
opts = append(opts, apperrors.WithOperation(classified.operation))
|
||||
}
|
||||
if classified.retryable != nil {
|
||||
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
|
||||
}
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
"send_message",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
serverSaysRetryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
"todo",
|
||||
"create_personal_todo",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-todo-create",
|
||||
ServerErrorCode: "999",
|
||||
ServerRetryable: &serverSaysRetryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
|
||||
t.Fatalf("hint = %q", typed.Hint)
|
||||
}
|
||||
for _, action := range typed.Actions {
|
||||
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
|
||||
t.Fatalf("misleading action = %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "upstream_internal_error",
|
||||
"origin": "dingtalk_api",
|
||||
"stage": "upstream_execution",
|
||||
"retryable": false,
|
||||
"trace_id": "trace-todo-create",
|
||||
"server_error_code": "999",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "999",
|
||||
"trace_id": "trace-todo-replay",
|
||||
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
CanonicalPath: "todo.create_personal_todo",
|
||||
Params: map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": "fixture",
|
||||
"executorIds": []string{"user-1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Fixture Java Engineer",
|
||||
"description": "Fixture backend development role",
|
||||
"jobNature": "FULL-TIME",
|
||||
"requiredEdu": 6,
|
||||
"minSalary": 20000,
|
||||
"maxSalary": 35000,
|
||||
"creatorUserId": "creator-user-id",
|
||||
"ownerUserIds": [
|
||||
"owner-user-id-1",
|
||||
"owner-user-id-2"
|
||||
],
|
||||
"extData": {
|
||||
"headCount": 1,
|
||||
"fullTimeExtData": {
|
||||
"salaryMonth": 12,
|
||||
"minJobExperience": 1,
|
||||
"maxJobExperience": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
// The organization mirror may be absent for long-lived local logins
|
||||
// that predate mirror publication. Recover from the legacy global
|
||||
// slot before giving up.
|
||||
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
|
||||
return recovered, nil
|
||||
}
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
@@ -891,6 +897,123 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||
return nil, refreshErr
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
|
||||
"corp_id", strings.TrimSpace(selected.CorpID),
|
||||
"user_id", strings.TrimSpace(selected.UserID),
|
||||
"refresh_error_code", exchangeErr.Code,
|
||||
)
|
||||
legacy, loadErr := tokenLoadKeychain()
|
||||
if loadErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
if legacy == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
|
||||
"step", "candidate_mismatch",
|
||||
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
|
||||
"legacy_user_id", strings.TrimSpace(legacy.UserID),
|
||||
)
|
||||
return nil, refreshErr
|
||||
}
|
||||
recovered := *legacy
|
||||
if strings.TrimSpace(recovered.UserID) == "" {
|
||||
recovered.UserID = strings.TrimSpace(selected.UserID)
|
||||
}
|
||||
if strings.TrimSpace(recovered.UserName) == "" {
|
||||
recovered.UserName = strings.TrimSpace(selected.UserName)
|
||||
}
|
||||
if recovered.IsAccessTokenValid() {
|
||||
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
|
||||
return &recovered, nil
|
||||
}
|
||||
if !recovered.IsRefreshTokenValid() {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
|
||||
if recoverErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
|
||||
if selected == nil || legacy == nil {
|
||||
return false
|
||||
}
|
||||
selectedCorpID := strings.TrimSpace(selected.CorpID)
|
||||
legacyCorpID := strings.TrimSpace(legacy.CorpID)
|
||||
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
|
||||
return false
|
||||
}
|
||||
selectedUserID := strings.TrimSpace(selected.UserID)
|
||||
legacyUserID := strings.TrimSpace(legacy.UserID)
|
||||
if legacyUserID != "" {
|
||||
return legacyUserID == selectedUserID
|
||||
}
|
||||
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
|
||||
}
|
||||
|
||||
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil || cfg == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
|
||||
return false
|
||||
}
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) != 1 {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "multi_profile",
|
||||
"corp_id", strings.TrimSpace(corpID),
|
||||
"profile_count", len(profiles),
|
||||
)
|
||||
return false
|
||||
}
|
||||
profile := profiles[0]
|
||||
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
|
||||
return true
|
||||
}
|
||||
profileUserID := ""
|
||||
if profile != nil {
|
||||
profileUserID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "identity_mismatch",
|
||||
"selected_user_id", strings.TrimSpace(userID),
|
||||
"profile_user_id", profileUserID,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
// external host, exchanges it for tokens, and persists them.
|
||||
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "V1039 User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
UserName: selected.UserName,
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
var saved *TokenData
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
|
||||
copy := *data
|
||||
saved = ©
|
||||
return nil
|
||||
})
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
|
||||
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
|
||||
}
|
||||
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
|
||||
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
|
||||
}
|
||||
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
|
||||
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-multi",
|
||||
UserID: "user-v1039-a",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
|
||||
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-blank-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
|
||||
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-user-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token owned by a different user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
expired := *fixture.blankToken
|
||||
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-single-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "Other User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-legacy-refresh",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
refreshed := &TokenData{
|
||||
AccessToken: "refreshed-legacy-access",
|
||||
RefreshToken: "rotated-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
refreshCalls := 0
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
refreshCalls++
|
||||
if refreshCalls == 1 {
|
||||
return nil, rejection
|
||||
}
|
||||
return refreshed, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if refreshCalls != 2 {
|
||||
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
|
||||
}
|
||||
if recovered.AccessToken != refreshed.AccessToken {
|
||||
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
|
||||
}
|
||||
if recovered.RefreshToken != refreshed.RefreshToken {
|
||||
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-profiles-error",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "shared-rejected-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-same-refresh",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: selected.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
|
||||
// nil selected must be rejected before any dereference.
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil selected error = %v, want original rejection", err)
|
||||
}
|
||||
|
||||
// A keychain load that returns (nil, nil) must be rejected before any dereference.
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-nil-legacy",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil legacy error = %v, want original rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
|
||||
|
||||
plainErr := errors.New("plain refresh failure")
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
|
||||
t.Fatalf("plain error = %v, want original plain failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-recovery-steps",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
t.Run("save_failure", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("save failure error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_expired", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "expired-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(-time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("expired refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, errors.New("legacy refresh failed")
|
||||
})
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("legacy refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("preflight error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
|
||||
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
|
||||
t.Fatal("nil legacy accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
|
||||
t.Fatal("different corp accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
|
||||
t.Fatal("blank selected corp accepted")
|
||||
}
|
||||
|
||||
blankSelected := &TokenData{CorpID: selected.CorpID}
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
|
||||
})
|
||||
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
|
||||
t.Fatal("both blank user IDs should match only through the single-profile guard")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
|
||||
t.Fatal("blank selected with non-blank legacy accepted")
|
||||
}
|
||||
|
||||
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
|
||||
t.Fatal("blank corp accepted by single-profile check")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -96,7 +96,14 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
|
||||
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src"}},
|
||||
})
|
||||
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
|
||||
})
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
|
||||
@@ -554,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
|
||||
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -646,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
+139
-9
@@ -16,9 +16,10 @@
|
||||
package cobracmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
// Tag as a group container: its RunE only prints help, so cobra's
|
||||
// Runnable() can't distinguish it from a real leaf — callers that need to
|
||||
// collapse empty groups rely on this annotation.
|
||||
cmdutil.MarkGroup(cmd)
|
||||
corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
mergeGroupPolicy(dst, src)
|
||||
if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) {
|
||||
dst.Short = src.Short
|
||||
}
|
||||
@@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
func mergeGroupPolicy(dst, src *cobra.Command) {
|
||||
dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err))
|
||||
}
|
||||
srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err))
|
||||
}
|
||||
if len(dst.Commands()) > 0 && !dstOK {
|
||||
panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath()))
|
||||
}
|
||||
if len(src.Commands()) > 0 && !srcOK {
|
||||
panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath()))
|
||||
}
|
||||
if dstOK && !srcOK {
|
||||
if !isNeutralMergePlaceholder(src) {
|
||||
panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q",
|
||||
src.CommandPath(), dst.CommandPath()))
|
||||
}
|
||||
return
|
||||
}
|
||||
if !dstOK && srcOK {
|
||||
if !isNeutralMergePlaceholder(dst) {
|
||||
panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q",
|
||||
src.CommandPath(), dst.CommandPath()))
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(dst, srcPolicy)
|
||||
return
|
||||
}
|
||||
if dstOK && srcOK && dstPolicy != srcPolicy {
|
||||
// A NavigationOnly/Reject/Sibling source is the framework's neutral
|
||||
// service scaffold (shortcuts and plugin overlays use it before being
|
||||
// folded into an owning product root). The destination owns the merged
|
||||
// command's default action and recovery scope, so preserve its policy.
|
||||
// Any stronger source declaration would lose behavior during this
|
||||
// destination-oriented merge and therefore fails closed.
|
||||
if srcPolicy != (corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
}) {
|
||||
panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v",
|
||||
dst.CommandPath(), dstPolicy, srcPolicy))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// isNeutralMergePlaceholder reports whether cmd contributes metadata only.
|
||||
// Such a shell may adopt (or be folded into) one typed group declaration.
|
||||
// Anything executable, parse-affecting, or child-bearing must declare its own
|
||||
// compatible GroupPolicy so tree assembly cannot silently discard behavior.
|
||||
func isNeutralMergePlaceholder(cmd *cobra.Command) bool {
|
||||
if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil ||
|
||||
cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil ||
|
||||
cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil ||
|
||||
cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil ||
|
||||
cmd.TraverseChildren || cmd.DisableFlagParsing {
|
||||
return false
|
||||
}
|
||||
hasFlags := false
|
||||
cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
|
||||
cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
|
||||
return !hasFlags
|
||||
}
|
||||
|
||||
// ValidateGroupTree checks the final assembled Cobra tree rather than source
|
||||
// syntax. Every command with children must carry one valid typed GroupPolicy;
|
||||
// leaves must carry none. This catches dynamically assembled aliases, plugin
|
||||
// parents, and constructors outside any one source directory.
|
||||
func ValidateGroupTree(root *cobra.Command) error {
|
||||
if root == nil {
|
||||
return fmt.Errorf("cannot validate a nil command tree")
|
||||
}
|
||||
return validateGroupNode(root)
|
||||
}
|
||||
|
||||
func validateGroupNode(cmd *cobra.Command) error {
|
||||
policy, declared, err := corecmd.GroupPolicyFor(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err)
|
||||
}
|
||||
children := cmd.Commands()
|
||||
if len(children) == 0 {
|
||||
if declared {
|
||||
return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !declared {
|
||||
return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath())
|
||||
}
|
||||
if !cmd.Runnable() {
|
||||
return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode)
|
||||
}
|
||||
if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) {
|
||||
return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath())
|
||||
}
|
||||
if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil {
|
||||
return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath())
|
||||
}
|
||||
if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil {
|
||||
return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath())
|
||||
}
|
||||
if policy.Positionals == corecmd.PositionalsReject {
|
||||
if cmd.Args == nil {
|
||||
return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) {
|
||||
return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath())
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := validateGroupNode(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAvailableDescendant(cmd *cobra.Command) bool {
|
||||
for _, child := range cmd.Commands() {
|
||||
if !child.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ShouldReplaceLeaf decides whether src should replace dst as a leaf command
|
||||
// based on override priority and local flag count.
|
||||
func ShouldReplaceLeaf(dst, src *cobra.Command) bool {
|
||||
|
||||
+391
-10
@@ -14,8 +14,10 @@
|
||||
package cobracmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -143,20 +145,399 @@ func TestNewGroupCommand(t *testing.T) {
|
||||
t.Fatalf("Short = %q, want %q", cmd.Short, "my group description")
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("Args should be set (cobra.NoArgs)")
|
||||
t.Fatal("Args should be set (cobra.ArbitraryArgs)")
|
||||
}
|
||||
// Verify Args rejects arguments.
|
||||
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
|
||||
t.Fatal("expected Args to reject extra arguments")
|
||||
// Args must reach the shared resolver instead of Cobra's generic arg error.
|
||||
if err := cmd.Args(cmd, []string{"extra"}); err != nil {
|
||||
t.Fatalf("Args intercepted command resolution: %v", err)
|
||||
}
|
||||
// Verify RunE is set and returns help (no error for valid invocation).
|
||||
if cmd.RunE == nil {
|
||||
t.Fatal("RunE should not be nil")
|
||||
}
|
||||
policy, ok, err := corecmd.GroupPolicyFor(cmd)
|
||||
if err != nil || !ok || policy != (corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
}) {
|
||||
t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err)
|
||||
}
|
||||
// RunE calls cmd.Help() which should not error.
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE returned unexpected error: %v", err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Fatalf("RunE typo error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) {
|
||||
t.Run("valid final tree", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
nested := NewGroupCommand("nested", "nested")
|
||||
nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.AddCommand(nested)
|
||||
if err := ValidateGroupTree(root); err != nil {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil tree", func(t *testing.T) {
|
||||
if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") {
|
||||
t.Fatalf("ValidateGroupTree(nil) = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("children require declaration", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaf rejects stale declaration", func(t *testing.T) {
|
||||
leaf := NewGroupCommand("stale", "stale")
|
||||
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) {
|
||||
leaf := &cobra.Command{Use: "stale-deep"}
|
||||
corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("declared group must stay runnable", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.RunE = nil
|
||||
root.Run = nil
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.Args = nil
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowed positionals require explicit Args contract", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation does not execute positional contracts", func(t *testing.T) {
|
||||
calls := 0
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Args: func(*cobra.Command, []string) error {
|
||||
calls++
|
||||
return nil
|
||||
},
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err != nil {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("ValidateGroupTree executed Args %d times", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep recovery requires available descendants", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) {
|
||||
t.Run("malformed policy metadata", func(t *testing.T) {
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Annotations: map[string]string{
|
||||
"dws.internal.corecmd.group_policy.v1": "malformed",
|
||||
},
|
||||
}
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("navigation-only execution hook changed", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.RunE = nil
|
||||
root.Run = func(*cobra.Command, []string) {}
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hybrid business execution hook removed", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.RunE = nil
|
||||
root.Run = func(*cobra.Command, []string) {}
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested validation error is propagated", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(NewGroupCommand("stale", "stale"))
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep recovery accepts an available descendant", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err != nil {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) {
|
||||
t.Run("copies source declaration", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
MergeCommandTree(dst, src)
|
||||
policy, ok, err := corecmd.GroupPolicyFor(dst)
|
||||
if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling {
|
||||
t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed destination accepts metadata-only source", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root", Long: "source details"}
|
||||
MergeCommandTree(dst, src)
|
||||
if dst.Long != "source details" {
|
||||
t.Fatalf("Long = %q", dst.Long)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts identical declarations", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := NewGroupCommand("root", "source")
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) {
|
||||
businessCalled := false
|
||||
dst := &cobra.Command{
|
||||
Use: "root",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
businessCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
want := corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(dst, want)
|
||||
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
|
||||
src := NewGroupCommand("root", "neutral scaffold")
|
||||
src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
MergeCommandTree(dst, src)
|
||||
|
||||
got, ok, err := corecmd.GroupPolicyFor(dst)
|
||||
if err != nil || !ok || got != want {
|
||||
t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want)
|
||||
}
|
||||
if ChildByName(dst, "overlay") == nil {
|
||||
t.Fatal("neutral scaffold child was not merged")
|
||||
}
|
||||
if err := dst.RunE(dst, nil); err != nil || !businessCalled {
|
||||
t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects conflicting declarations", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root"}
|
||||
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
src := &cobra.Command{Use: "root"}
|
||||
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
src := NewGroupCommand("root", "source")
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
src.Flags().String("source-only", "", "must not be silently discarded")
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root", Args: cobra.NoArgs}
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("rejects undeclared destination group", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst.AddCommand(&cobra.Command{Use: "child"})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, &cobra.Command{Use: "root"})
|
||||
})
|
||||
|
||||
t.Run("rejects undeclared source group", func(t *testing.T) {
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src.AddCommand(&cobra.Command{Use: "child"})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(&cobra.Command{Use: "root"}, src)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) {
|
||||
mustPanic := func(t *testing.T, want string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
got := recover()
|
||||
message, ok := got.(string)
|
||||
if !ok || !strings.Contains(message, want) {
|
||||
t.Fatalf("panic = %v, want substring %q", got, want)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
malformed := func() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "root",
|
||||
Annotations: map[string]string{
|
||||
"dws.internal.corecmd.group_policy.v1": "malformed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("malformed destination policy", func(t *testing.T) {
|
||||
mustPanic(t, "destination command", func() {
|
||||
MergeCommandTree(malformed(), &cobra.Command{Use: "root"})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("malformed source policy", func(t *testing.T) {
|
||||
mustPanic(t, "source command", func() {
|
||||
MergeCommandTree(&cobra.Command{Use: "root"}, malformed())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("local flag prevents placeholder merge", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src.Flags().String("local", "", "local parse behavior")
|
||||
mustPanic(t, "behavior-bearing leaf", func() {
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("persistent flag prevents placeholder merge", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src.PersistentFlags().String("persistent", "", "inherited parse behavior")
|
||||
mustPanic(t, "behavior-bearing leaf", func() {
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewHiddenGroupCommand(t *testing.T) {
|
||||
@@ -341,11 +722,11 @@ func TestMergeCommandTree(t *testing.T) {
|
||||
|
||||
t.Run("child merge recursive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dstChild := &cobra.Command{Use: "sub", Short: ""}
|
||||
dst.AddCommand(dstChild)
|
||||
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
srcChild := &cobra.Command{Use: "sub", Short: "Merged short"}
|
||||
src.AddCommand(srcChild)
|
||||
|
||||
@@ -361,12 +742,12 @@ func TestMergeCommandTree(t *testing.T) {
|
||||
|
||||
t.Run("leaf replacement by higher priority", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dstLeaf := &cobra.Command{Use: "leaf", Short: "old"}
|
||||
SetOverridePriority(dstLeaf, 1)
|
||||
dst.AddCommand(dstLeaf)
|
||||
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
srcLeaf := &cobra.Command{Use: "leaf", Short: "new"}
|
||||
SetOverridePriority(srcLeaf, 5)
|
||||
src.AddCommand(srcLeaf)
|
||||
@@ -383,10 +764,10 @@ func TestMergeCommandTree(t *testing.T) {
|
||||
|
||||
t.Run("new child addition", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dst.AddCommand(&cobra.Command{Use: "existing"})
|
||||
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"})
|
||||
|
||||
MergeCommandTree(dst, src)
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package corecmd is the shared, dispatch-agnostic base for building leaf
|
||||
// commands. It concentrates flag registration, the alias/env/default effective
|
||||
// value fallback chain, required validation, cross-flag constraint declaration
|
||||
// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs
|
||||
// assembly, and Agent Runtime Schema projection.
|
||||
// Package corecmd is the shared, dispatch-agnostic base for building commands.
|
||||
// It concentrates typed group policy, flag registration, the alias/env/default
|
||||
// effective value fallback chain, required validation, cross-flag constraint
|
||||
// declaration checks + runtime enforcement, SafetySpec-driven confirmation,
|
||||
// toolArgs assembly, and Agent Runtime Schema projection.
|
||||
//
|
||||
// Declaration vs execution (framework rule):
|
||||
//
|
||||
@@ -231,7 +231,9 @@ const (
|
||||
// or assemble business params that belong in Flags/ConstParams.
|
||||
//
|
||||
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
|
||||
// validates this at construction time. corecmd stays dispatch-agnostic and
|
||||
// validates this at construction time. Non-leaf commands are declared
|
||||
// separately through ApplyGroupPolicy so leaf execution fields can never be
|
||||
// configured and then silently ignored. corecmd stays dispatch-agnostic and
|
||||
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
|
||||
// body.
|
||||
type Spec struct {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GroupMode declares whether a command with children is navigation-only or
|
||||
// also owns business execution. The zero value means the command is a leaf.
|
||||
type GroupMode string
|
||||
|
||||
const (
|
||||
// GroupNavigationOnly is a parent whose own invocation only renders help.
|
||||
GroupNavigationOnly GroupMode = "navigation_only"
|
||||
// GroupHybrid is a runnable business command that also owns children.
|
||||
GroupHybrid GroupMode = "hybrid"
|
||||
)
|
||||
|
||||
// PositionalsPolicy declares whether a group may consume positional values.
|
||||
type PositionalsPolicy string
|
||||
|
||||
const (
|
||||
// PositionalsReject makes every unmatched positional token eligible for
|
||||
// command-resolution recovery rather than business execution.
|
||||
PositionalsReject PositionalsPolicy = "reject"
|
||||
// PositionalsAllow reserves positional values for the group's business
|
||||
// execution. Recovery must therefore be disabled to avoid ambiguity.
|
||||
PositionalsAllow PositionalsPolicy = "allow"
|
||||
)
|
||||
|
||||
// RecoveryPolicy declares the search scope for unknown-command recovery.
|
||||
type RecoveryPolicy string
|
||||
|
||||
const (
|
||||
// RecoverySibling suggests only direct children of the current group.
|
||||
RecoverySibling RecoveryPolicy = "sibling"
|
||||
// RecoveryDeep may search all descendants of the current group.
|
||||
RecoveryDeep RecoveryPolicy = "deep"
|
||||
// RecoveryDisabled leaves positional handling entirely to Cobra or the
|
||||
// command's business execution.
|
||||
RecoveryDisabled RecoveryPolicy = "disabled"
|
||||
)
|
||||
|
||||
// GroupPolicy is the typed declaration for every non-leaf command.
|
||||
//
|
||||
// Its zero value deliberately means "leaf": callers must declare all three
|
||||
// fields together for a group. ApplyGroupPolicy compiles the declaration to
|
||||
// Cobra behavior and private framework metadata; command authors must not
|
||||
// author parallel kind annotations themselves.
|
||||
type GroupPolicy struct {
|
||||
Mode GroupMode
|
||||
Positionals PositionalsPolicy
|
||||
Recovery RecoveryPolicy
|
||||
}
|
||||
|
||||
const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1"
|
||||
|
||||
// IsZero reports whether p is the leaf declaration.
|
||||
func (p GroupPolicy) IsZero() bool {
|
||||
return p.Mode == "" && p.Positionals == "" && p.Recovery == ""
|
||||
}
|
||||
|
||||
// ValidateGroupPolicy rejects partial declarations, unknown enum values, and
|
||||
// combinations whose parsing semantics would be ambiguous.
|
||||
func ValidateGroupPolicy(p GroupPolicy) error {
|
||||
if p.IsZero() {
|
||||
return nil
|
||||
}
|
||||
switch p.Mode {
|
||||
case GroupNavigationOnly, GroupHybrid:
|
||||
default:
|
||||
return fmt.Errorf("invalid group mode %q", p.Mode)
|
||||
}
|
||||
switch p.Positionals {
|
||||
case PositionalsReject, PositionalsAllow:
|
||||
default:
|
||||
return fmt.Errorf("invalid group positionals policy %q", p.Positionals)
|
||||
}
|
||||
switch p.Recovery {
|
||||
case RecoverySibling, RecoveryDeep, RecoveryDisabled:
|
||||
default:
|
||||
return fmt.Errorf("invalid group recovery policy %q", p.Recovery)
|
||||
}
|
||||
if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject {
|
||||
return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject)
|
||||
}
|
||||
if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled {
|
||||
return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior.
|
||||
// Invalid declarations panic because they are framework construction bugs,
|
||||
// matching the fail-closed behavior of Spec flag/constraint declarations.
|
||||
//
|
||||
// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid
|
||||
// groups retain their existing RunE; when they reject positionals, a wrapper
|
||||
// sends non-empty args to the same unknown-command resolver before invoking
|
||||
// business execution. When recovery is enabled, rejecting positionals
|
||||
// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the
|
||||
// token with a generic error before command resolution can produce bounded
|
||||
// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs.
|
||||
func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) {
|
||||
if cmd == nil {
|
||||
panic("cannot apply GroupPolicy to a nil command")
|
||||
}
|
||||
if policy.IsZero() {
|
||||
panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name()))
|
||||
}
|
||||
if err := ValidateGroupPolicy(policy); err != nil {
|
||||
panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err))
|
||||
}
|
||||
if existing, ok, err := GroupPolicyFor(cmd); err != nil {
|
||||
panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err))
|
||||
} else if ok && existing != policy {
|
||||
panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy))
|
||||
} else if ok {
|
||||
return
|
||||
}
|
||||
|
||||
if policy.Mode == GroupNavigationOnly {
|
||||
cmd.Run = nil
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
return runGroupPolicy(cmd, args, policy)
|
||||
}
|
||||
} else if cmd.RunE == nil {
|
||||
panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name()))
|
||||
} else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
|
||||
businessRunE := cmd.RunE
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return runGroupPolicy(cmd, args, policy)
|
||||
}
|
||||
return businessRunE(cmd, args)
|
||||
}
|
||||
}
|
||||
if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
} else if policy.Positionals == PositionalsReject {
|
||||
cmd.Args = cobra.NoArgs
|
||||
}
|
||||
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy)
|
||||
}
|
||||
|
||||
// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is
|
||||
// false only for a leaf. Malformed private metadata is returned as an error so
|
||||
// tree assembly can fail closed instead of silently treating a group as a leaf.
|
||||
func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return GroupPolicy{}, false, nil
|
||||
}
|
||||
raw, ok := cmd.Annotations[groupPolicyAnnotation]
|
||||
if !ok {
|
||||
return GroupPolicy{}, false, nil
|
||||
}
|
||||
parts := strings.Split(raw, "|")
|
||||
if len(parts) != 3 {
|
||||
return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw)
|
||||
}
|
||||
policy := GroupPolicy{
|
||||
Mode: GroupMode(parts[0]),
|
||||
Positionals: PositionalsPolicy(parts[1]),
|
||||
Recovery: RecoveryPolicy(parts[2]),
|
||||
}
|
||||
if err := ValidateGroupPolicy(policy); err != nil {
|
||||
return GroupPolicy{}, false, err
|
||||
}
|
||||
if policy.IsZero() {
|
||||
return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero")
|
||||
}
|
||||
return policy, true, nil
|
||||
}
|
||||
|
||||
func encodeGroupPolicy(policy GroupPolicy) string {
|
||||
return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery)
|
||||
}
|
||||
|
||||
func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
input := strings.TrimSpace(args[0])
|
||||
reason := cmdutil.ClassifyCommandResolution(cmd, input)
|
||||
suggestions := cmdutil.SuggestSubcommands(cmd, input)
|
||||
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep {
|
||||
if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 {
|
||||
suggestions = deep
|
||||
}
|
||||
}
|
||||
return cmdutil.NewCommandResolution(
|
||||
cmd,
|
||||
input,
|
||||
reason,
|
||||
suggestions,
|
||||
"",
|
||||
).Err()
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) {
|
||||
valid := []GroupPolicy{
|
||||
{},
|
||||
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling},
|
||||
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep},
|
||||
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled},
|
||||
}
|
||||
for _, policy := range valid {
|
||||
if err := ValidateGroupPolicy(policy); err != nil {
|
||||
t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
policy GroupPolicy
|
||||
needle string
|
||||
}{
|
||||
{name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"},
|
||||
{name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"},
|
||||
{name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"},
|
||||
{name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"},
|
||||
{name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"},
|
||||
{name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||
t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) {
|
||||
policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}
|
||||
cmd := &cobra.Command{Use: "parent"}
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
|
||||
got, ok, err := GroupPolicyFor(cmd)
|
||||
if err != nil || !ok || got != policy {
|
||||
t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy)
|
||||
}
|
||||
if !cmd.Runnable() || cmd.TraverseChildren {
|
||||
t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren)
|
||||
}
|
||||
if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil {
|
||||
t.Fatal("PositionalsReject must let command resolution inspect unmatched args")
|
||||
}
|
||||
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Fatalf("navigation recovery error = %v", err)
|
||||
}
|
||||
var help strings.Builder
|
||||
cmd.SetOut(&help)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("navigation help error = %v", err)
|
||||
}
|
||||
if output := help.String(); !strings.Contains(output, "Usage:") {
|
||||
t.Fatalf("navigation help output = %q", output)
|
||||
}
|
||||
// Re-applying the same declaration is idempotent.
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
|
||||
traversing := &cobra.Command{Use: "traversing", TraverseChildren: true}
|
||||
ApplyGroupPolicy(traversing, policy)
|
||||
if !traversing.TraverseChildren {
|
||||
t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "leaf"}
|
||||
if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() {
|
||||
t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}}
|
||||
if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() {
|
||||
t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
ApplyGroupPolicy(root, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
parent := &cobra.Command{
|
||||
Use: "search",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
parent.Flags().String("dimension", "", "parent-only search dimension")
|
||||
ApplyGroupPolicy(parent, GroupPolicy{
|
||||
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
childCalled := false
|
||||
child := &cobra.Command{
|
||||
Use: "enterprise",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
childCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent.AddCommand(child)
|
||||
root.AddCommand(parent)
|
||||
root.SetArgs([]string{"search", "--dimension", "name", "enterprise"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") {
|
||||
t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err)
|
||||
}
|
||||
if childCalled {
|
||||
t.Fatal("parent local flag leaked into child command execution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) {
|
||||
called := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "hybrid",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{
|
||||
Mode: GroupHybrid,
|
||||
Positionals: PositionalsAllow,
|
||||
Recovery: RecoveryDisabled,
|
||||
})
|
||||
if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called {
|
||||
t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) {
|
||||
called := false
|
||||
wrapperFrames := 0
|
||||
cmd := &cobra.Command{
|
||||
Use: "hybrid",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
called = true
|
||||
pcs := make([]uintptr, 32)
|
||||
frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)])
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") {
|
||||
wrapperFrames++
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
policy := GroupPolicy{
|
||||
Mode: GroupHybrid,
|
||||
Positionals: PositionalsReject,
|
||||
Recovery: RecoverySibling,
|
||||
}
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
// Applying the identical declaration must be a no-op. In particular, it
|
||||
// must not wrap the already wrapped Hybrid RunE a second time.
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Fatalf("hybrid typo error = %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("unknown positional must not reach hybrid business RunE")
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil || !called {
|
||||
t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err)
|
||||
}
|
||||
if wrapperFrames != 1 {
|
||||
t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
rangeGroup := &cobra.Command{Use: "range"}
|
||||
rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
|
||||
ApplyGroupPolicy(rangeGroup, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
sheet.AddCommand(
|
||||
rangeGroup,
|
||||
&cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
ApplyGroupPolicy(sheet, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep,
|
||||
})
|
||||
root.AddCommand(sheet)
|
||||
|
||||
err := sheet.RunE(sheet, []string{"read"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) {
|
||||
t.Fatalf("deep recovery error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) ||
|
||||
structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` {
|
||||
t.Fatalf("deep recovery = %#v", structured)
|
||||
}
|
||||
if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) {
|
||||
t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"])
|
||||
}
|
||||
|
||||
err = sheet.RunE(sheet, []string{"+list-sheet"})
|
||||
structured = nil
|
||||
if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) {
|
||||
t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err)
|
||||
}
|
||||
if !slices.Equal(structured.Actions, []string{
|
||||
"Run 'dws sheet --help' for the full list",
|
||||
"Run 'dws shortcut list --service sheet --format json'",
|
||||
}) {
|
||||
t.Fatalf("direct shortcut actions = %#v", structured.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "parent"}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled,
|
||||
})
|
||||
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
|
||||
t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) {
|
||||
mustPanic := func(name, needle string, fn func()) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), needle) {
|
||||
t.Fatalf("panic = %v, want %q", got, needle)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
})
|
||||
}
|
||||
mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) })
|
||||
mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) })
|
||||
mustPanic("invalid policy", "invalid GroupPolicy", func() {
|
||||
ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{
|
||||
Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled,
|
||||
})
|
||||
})
|
||||
mustPanic("hybrid must run", "must declare RunE", func() {
|
||||
ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{
|
||||
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
})
|
||||
mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() {
|
||||
cmd := &cobra.Command{Use: "parent"}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled})
|
||||
})
|
||||
mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() {
|
||||
cmd := &cobra.Command{
|
||||
Use: "broken",
|
||||
Annotations: map[string]string{
|
||||
groupPolicyAnnotation: "hybrid|reject|unexpected",
|
||||
},
|
||||
}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) {
|
||||
for name, test := range map[string]struct {
|
||||
encoded string
|
||||
needle string
|
||||
}{
|
||||
"malformed": {encoded: "hybrid|reject", needle: "malformed"},
|
||||
"invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"},
|
||||
"zero policy": {encoded: "||", needle: "must not be zero"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "broken",
|
||||
Annotations: map[string]string{
|
||||
groupPolicyAnnotation: test.encoded,
|
||||
},
|
||||
}
|
||||
if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) {
|
||||
t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newAgoalCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "agoal",
|
||||
Short: "Agoal 管理",
|
||||
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
|
||||
@@ -39,11 +39,11 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal obj-template list 获取目标模板列表
|
||||
dws agoal obj-template create-or-update 新增或更新目标模板`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── strategy: 战略解码管理 ──────────────────────────────────
|
||||
|
||||
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
|
||||
strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE})
|
||||
|
||||
strategyListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -139,7 +139,7 @@ scopeType 支持:
|
||||
|
||||
// ── contract: 经营合约管理 ──────────────────────────────────
|
||||
|
||||
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
|
||||
contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE})
|
||||
|
||||
contractListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -258,7 +258,7 @@ scopeType 支持:
|
||||
|
||||
// ── scorecard: 计分卡管理 ───────────────────────────────────
|
||||
|
||||
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
|
||||
scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE})
|
||||
|
||||
scorecardDetailCmd := &cobra.Command{
|
||||
Use: "detail",
|
||||
@@ -394,7 +394,7 @@ scopeType 支持:
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
|
||||
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
|
||||
|
||||
userRulesCmd := &cobra.Command{
|
||||
Use: "rules",
|
||||
@@ -445,7 +445,7 @@ scopeType 支持:
|
||||
|
||||
// ── report: 周月报管理 ──────────────────────────────────────
|
||||
|
||||
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
|
||||
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE})
|
||||
|
||||
reportListStatisticsCmd := &cobra.Command{
|
||||
Use: "list-statistics",
|
||||
@@ -520,7 +520,7 @@ scopeType 支持:
|
||||
|
||||
// ── template: 目标模板管理 ──────────────────────────────────
|
||||
|
||||
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
|
||||
objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE})
|
||||
|
||||
objTemplateListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
|
||||
return groupRunE(cmd, args)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(root)
|
||||
|
||||
// root 和 person 各自定义同一组本地 flag,这样:
|
||||
// - dws aisearch --query xxx ← root 自己能解析
|
||||
|
||||
+26
-24
@@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "aitable",
|
||||
Short: "AI 表格操作",
|
||||
Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。
|
||||
@@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`,
|
||||
RunE: groupRunE,
|
||||
SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos
|
||||
}
|
||||
})
|
||||
|
||||
// ── base: Base 管理 ─────────────────────────────────────────
|
||||
|
||||
baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}
|
||||
baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE})
|
||||
|
||||
baseGetPrimaryDocIdCmd := &cobra.Command{
|
||||
Use: "get-primary-doc-id",
|
||||
@@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错
|
||||
|
||||
// ── table: 数据表管理 ───────────────────────────────────────
|
||||
|
||||
tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}
|
||||
tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE})
|
||||
|
||||
tableGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -2223,7 +2223,7 @@ config 结构参考:
|
||||
|
||||
// ── field: 字段管理 ─────────────────────────────────────────
|
||||
|
||||
fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}
|
||||
fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE})
|
||||
|
||||
fieldGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。
|
||||
|
||||
// ── record: 记录管理 ────────────────────────────────────────
|
||||
|
||||
recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}
|
||||
recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE})
|
||||
|
||||
recordQueryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
@@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
|
||||
// ── template: 模板搜索 ──────────────────────────────────────
|
||||
|
||||
templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}
|
||||
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE})
|
||||
|
||||
templateSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
|
||||
// ── attachment: 附件管理 ──────────────────────────────────────
|
||||
|
||||
attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}
|
||||
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE})
|
||||
|
||||
attachmentUploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
@@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
|
||||
// ── view: 视图管理 ───────────────────────────────────────────
|
||||
|
||||
viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}
|
||||
viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE})
|
||||
|
||||
viewGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
return callAitableTool("get_views", toolArgs)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(viewGetCmd)
|
||||
|
||||
// ─── view get <attr> 子命令:按属性投影 view 响应 ──────────────
|
||||
// card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。
|
||||
@@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。
|
||||
return callAitableTool("update_view", toolArgs)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(viewUpdateCmd)
|
||||
|
||||
// ─── view update <attr> 子命令:按属性局部更新 ────────────────────
|
||||
|
||||
@@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
// ── form: 表单管理 ──────────────────────────────────────────
|
||||
|
||||
formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}
|
||||
formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}
|
||||
formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}
|
||||
formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE})
|
||||
formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE})
|
||||
formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE})
|
||||
|
||||
formListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
// ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ──
|
||||
|
||||
formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}
|
||||
formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE})
|
||||
|
||||
formQuestionsCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
// ── workflow: 自动化工作流管理 ────────────────────────────────
|
||||
|
||||
workflowCmd := &cobra.Command{
|
||||
workflowCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "workflow",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
workflowCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
|
||||
// ── dashboard: 仪表盘管理 ────────────────────────────────────
|
||||
|
||||
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
|
||||
dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE})
|
||||
|
||||
dashboardConfigExampleCmd := &cobra.Command{
|
||||
Use: "config-example",
|
||||
@@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── dashboard share: 仪表盘分享管理 ────────────────────────────
|
||||
|
||||
dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}
|
||||
dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE})
|
||||
|
||||
dashboardShareGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── chart: 图表管理 ──────────────────────────────────────────
|
||||
|
||||
chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}
|
||||
chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE})
|
||||
|
||||
chartWidgetsExampleCmd := &cobra.Command{
|
||||
Use: "widgets-example",
|
||||
@@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── chart share: 图表分享管理 ────────────────────────────────
|
||||
|
||||
chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}
|
||||
chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE})
|
||||
|
||||
chartShareGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── export / import: 数据导入导出 ────────────────────────────
|
||||
|
||||
exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}
|
||||
exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE})
|
||||
|
||||
exportDataCmd := &cobra.Command{
|
||||
Use: "data",
|
||||
@@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with
|
||||
},
|
||||
})
|
||||
|
||||
importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}
|
||||
importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE})
|
||||
|
||||
// ── advperm: 高级权限 / 自定义角色 ────────────────────────────
|
||||
|
||||
advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}
|
||||
advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE})
|
||||
|
||||
advpermEnableCmd := &cobra.Command{
|
||||
Use: "enable",
|
||||
@@ -7392,7 +7394,7 @@ role-get 自行 merge)。
|
||||
|
||||
// ── section: 文件夹与节点管理(导航树组织) ──────────────────────────────
|
||||
|
||||
sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}
|
||||
sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE})
|
||||
|
||||
sectionCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -8424,7 +8426,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
|
||||
// ── datasource: 数据源同步管理 ──────────────────────────────
|
||||
|
||||
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
|
||||
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
|
||||
|
||||
datasourceGetConfigCmd := &cobra.Command{
|
||||
Use: "get-config",
|
||||
|
||||
@@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "attendance",
|
||||
Short: "考勤打卡 / 排班 / 统计",
|
||||
Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。
|
||||
@@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command {
|
||||
globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知)
|
||||
vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── record ───────────────────────────────────────────────
|
||||
|
||||
attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}
|
||||
attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE})
|
||||
|
||||
attendanceRecordGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── check ────────────────────────────────────────────────
|
||||
|
||||
attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}
|
||||
attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE})
|
||||
|
||||
// MCP tool: query_check_result
|
||||
attendanceCheckResultCmd := &cobra.Command{
|
||||
@@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── approve ────────────────────────────────────────────────
|
||||
|
||||
attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}
|
||||
attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE})
|
||||
|
||||
// 审批类型关键词到 bizType 数字映射
|
||||
// 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类),
|
||||
@@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── shift ────────────────────────────────────────────────
|
||||
|
||||
attendanceShiftCmd := &cobra.Command{
|
||||
attendanceShiftCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "shift",
|
||||
Short: "班次查询",
|
||||
Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。
|
||||
返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: batch_get_employee_shifts
|
||||
attendanceShiftListCmd := &cobra.Command{
|
||||
@@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── class ────────────────────────────────────────────────
|
||||
|
||||
attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}
|
||||
attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_class_list
|
||||
attendanceClassSearchCmd := &cobra.Command{
|
||||
@@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
|
||||
|
||||
// ── adjustment-rule ────────────────────────────────────
|
||||
|
||||
attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}
|
||||
attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_adjustment_rule_detail
|
||||
attendanceAdjustmentGetCmd := &cobra.Command{
|
||||
@@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
|
||||
|
||||
// ── overtime-rule ──────────────────────────────────────
|
||||
|
||||
attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}
|
||||
attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_overtime_rule_detail
|
||||
attendanceOvertimeGetCmd := &cobra.Command{
|
||||
@@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
|
||||
|
||||
// ── group ──────────────────────────────────────────────
|
||||
|
||||
attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}
|
||||
attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_simple_groups
|
||||
attendanceGroupSearchCmd := &cobra.Command{
|
||||
@@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
})
|
||||
|
||||
// ── selfsetting ─────────────────────────────────────────────
|
||||
attendanceSelfSettingCmd := &cobra.Command{
|
||||
attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "selfsetting",
|
||||
Short: "个人规则设置",
|
||||
Long: `个人规则设置相关命令。
|
||||
@@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
|
||||
save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: query_self_setting
|
||||
attendanceSelfSettingGetCmd := &cobra.Command{
|
||||
@@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
})
|
||||
|
||||
// ── globalsetting ────────────────────────────────────────
|
||||
attendanceGlobalSettingCmd := &cobra.Command{
|
||||
attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "globalsetting",
|
||||
Short: "全局规则设置(仅管理员)",
|
||||
Long: `全局规则设置相关命令,仅管理员可以调用。
|
||||
@@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
|
||||
save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: query_global_setting
|
||||
attendanceGlobalSettingGetCmd := &cobra.Command{
|
||||
@@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── report ──────────────────────────────────────────────
|
||||
|
||||
attendanceReportCmd := &cobra.Command{
|
||||
attendanceReportCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "report",
|
||||
Short: "查询考勤报表和结果",
|
||||
Long: `考勤 MCP 报表接口,仅对管理员开放
|
||||
@@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
query-data 根据字段查询考勤数据
|
||||
query-leave 查询用户假期数据`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: get_report_columns
|
||||
reportColumnsCmd := &cobra.Command{
|
||||
@@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── 假期 vacation ───────────────────────────────────────────────
|
||||
|
||||
attendanceVacationCmd := &cobra.Command{
|
||||
attendanceVacationCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "vacation",
|
||||
Short: "假期管理",
|
||||
Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。
|
||||
@@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
save-balance 更新员工假期余额
|
||||
records 查询指定员工假期余额变更记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 假期规则 types ─────────────────────────────────────────
|
||||
|
||||
@@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── schedule ──────────────────────────────────────────────
|
||||
|
||||
attendanceScheduleCmd := &cobra.Command{
|
||||
attendanceScheduleCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "schedule",
|
||||
Short: "排班管理",
|
||||
Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。
|
||||
@@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
import 导入排班记录到排班制考勤组
|
||||
get 获取指定用户的排班记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// schedule import (generateTurnSchedule)
|
||||
scheduleImportCmd := &cobra.Command{
|
||||
@@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── checkin ──────────────────────────────────────────────
|
||||
|
||||
checkinCmd := &cobra.Command{
|
||||
checkinCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "checkin",
|
||||
Short: "签到管理",
|
||||
Long: `签到记录的查询。
|
||||
子命令:
|
||||
records 查询指定员工的签到记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: queryUserRecordByStaffIds
|
||||
checkinRecordsCmd := &cobra.Command{
|
||||
|
||||
+13
-200
@@ -2,14 +2,11 @@ package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -18,10 +15,9 @@ import (
|
||||
// dws calendar — 日历产品命令组
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that prints
|
||||
// a warning-level "Did you mean" hint to stderr (instead of returning an Error)
|
||||
// and exits 0. Scoped to calendar.go on purpose so the shared cmdutil.HintSubCmd
|
||||
// used by other products keeps returning errors as before.
|
||||
// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that returns
|
||||
// the shared typed validation error while preserving Calendar's reviewed
|
||||
// replacement guidance.
|
||||
//
|
||||
// The `suggestion` argument should be the bare corrected command (no leading
|
||||
// "use:" / "hint:" prefix); the helper wraps it with the standard "Did you
|
||||
@@ -29,186 +25,9 @@ import (
|
||||
func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command {
|
||||
c := hintSubCmd(use, suggestion)
|
||||
c.DisableFlagParsing = true
|
||||
c.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "warning: command %q does not exist\n hint: %s\t %s\n more: %s \n",
|
||||
cmd.Parent().CommandPath()+" "+use,
|
||||
suggestion,
|
||||
"[MUST] use --help to see command detail",
|
||||
"'dws calendar --help' to see all available commands")
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// installUnknownVerbFallback makes `group` emit a consistent warning-style
|
||||
// "Did you mean" hint whenever the caller types an unknown subcommand under
|
||||
// that group, regardless of whether extra flags follow. This is a blanket
|
||||
// safety net that covers every verb we never thought to pre-register via
|
||||
// calendarInfoHintSubCmd (e.g. `dws calendar room query --min-duration 30`).
|
||||
//
|
||||
// Two Cobra knobs make this work together:
|
||||
// - FParseErrWhitelist.UnknownFlags=true stops pflag from aborting with
|
||||
// "unknown flag: --xxx" before RunE ever runs.
|
||||
// - Args=cobra.ArbitraryArgs lets Cobra pass the bad verb through as the
|
||||
// first positional arg instead of rejecting it.
|
||||
//
|
||||
// If the user types a *known* subcommand, Cobra still dispatches to that
|
||||
// child's RunE as usual; this fallback only fires when resolution stops at
|
||||
// `group` with leftover args.
|
||||
func installUnknownVerbFallback(group *cobra.Command) {
|
||||
group.FParseErrWhitelist.UnknownFlags = true
|
||||
group.Args = cobra.ArbitraryArgs
|
||||
|
||||
// Override HelpFunc so that `<group> <unknown-verb> --help` also shows
|
||||
// the "unknown subcommand" error instead of silently printing help.
|
||||
// Cobra intercepts --help before RunE, so without this the fallback
|
||||
// would never fire when --help is present.
|
||||
origHelp := group.HelpFunc()
|
||||
group.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == group {
|
||||
// HelpFunc receives os.Args[1:] (full arg slice without binary).
|
||||
// Strip tokens matching the resolved command path to get actual
|
||||
// leftover args that should be checked for unknown verbs.
|
||||
depth := len(strings.Fields(cmd.CommandPath())) - 1
|
||||
leftover := stripCommandPrefix(args, depth)
|
||||
if bad := findUnknownVerb(cmd, leftover); bad != "" {
|
||||
printUnknownSubcmdError(cmd, bad)
|
||||
return
|
||||
}
|
||||
origHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
// For non-group commands, render base help then apply the safety
|
||||
// annotation. Recursion safety hinges on NOT calling
|
||||
// cmd.Root().HelpFunc(): in test trees calendar IS the root, so that
|
||||
// would re-enter this wrapper. origHelp was captured before any
|
||||
// wrapping and is the plain cobra renderer.
|
||||
origHelp(cmd, args)
|
||||
cli.RenderSafetyAnnotation(cmd)
|
||||
})
|
||||
|
||||
prev := group.RunE
|
||||
group.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
// Unknown flags whitelisted by pflag may leak into args. Pick the first
|
||||
// non-flag token as the offending verb.
|
||||
if bad := findUnknownVerb(cmd, args); bad != "" {
|
||||
printUnknownSubcmdError(cmd, bad)
|
||||
return nil
|
||||
}
|
||||
// No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently
|
||||
// swallows bad flags, scan the original os.Args for flags unregistered
|
||||
// on this command and report them explicitly.
|
||||
if flag := findUnknownFlag(cmd); flag != "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", flag)
|
||||
fmt.Fprintf(os.Stderr, " hint: Run '%s --help' to see available options\n", cmd.CommandPath())
|
||||
return nil
|
||||
}
|
||||
if prev != nil {
|
||||
return prev(cmd, args)
|
||||
}
|
||||
return cmd.Help()
|
||||
}
|
||||
}
|
||||
|
||||
// findUnknownVerb returns the first positional arg that is not a registered
|
||||
// subcommand (or alias) of cmd. Returns "" if all args are flags or known.
|
||||
func findUnknownVerb(cmd *cobra.Command, args []string) string {
|
||||
for _, a := range args {
|
||||
if strings.HasPrefix(a, "-") {
|
||||
continue
|
||||
}
|
||||
isKnown := false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == a {
|
||||
isKnown = true
|
||||
break
|
||||
}
|
||||
for _, alias := range c.Aliases {
|
||||
if alias == a {
|
||||
isKnown = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isKnown {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isKnown {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// printUnknownSubcmdError prints the standard "unknown subcommand" error to
|
||||
// stderr with available commands and a did-you-mean hint.
|
||||
func printUnknownSubcmdError(cmd *cobra.Command, bad string) {
|
||||
var available []string
|
||||
for _, c := range cmd.Commands() {
|
||||
if !c.Hidden && c.Name() != "help" {
|
||||
available = append(available, c.Name())
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Error: unknown subcommand %q for %q\n", bad, cmd.CommandPath())
|
||||
fmt.Fprintf(os.Stderr, " available: %s\n", strings.Join(available, ", "))
|
||||
if s := cmd.SuggestionsFor(bad); len(s) > 0 {
|
||||
fmt.Fprintf(os.Stderr, " hint: did you mean %q\n", cmd.CommandPath()+" "+s[0])
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, " hint: %s --help\n", cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
|
||||
// stripCommandPrefix strips the first `depth` non-flag tokens from args.
|
||||
// This is needed because Cobra's HelpFunc receives os.Args[1:] (the full arg
|
||||
// slice without the binary name), including the resolved command path tokens.
|
||||
// depth should be len(strings.Fields(cmd.CommandPath())) - 1.
|
||||
func stripCommandPrefix(args []string, depth int) []string {
|
||||
skipped := 0
|
||||
for i, a := range args {
|
||||
if skipped >= depth {
|
||||
return args[i:]
|
||||
}
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findUnknownFlag scans os.Args for flags that are not registered on cmd.
|
||||
// Returns the first offending flag token (e.g. "--today") or "".
|
||||
func findUnknownFlag(cmd *cobra.Command) string {
|
||||
depth := len(strings.Fields(cmd.CommandPath())) - 1
|
||||
leftover := stripCommandPrefix(os.Args[1:], depth)
|
||||
for i := 0; i < len(leftover); i++ {
|
||||
a := leftover[i]
|
||||
if a == "--" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(a, "--") {
|
||||
name := a[2:]
|
||||
if eqIdx := strings.Index(name, "="); eqIdx >= 0 {
|
||||
name = name[:eqIdx]
|
||||
}
|
||||
if name == "help" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
return a
|
||||
}
|
||||
} else if strings.HasPrefix(a, "-") && a != "-" {
|
||||
ch := a[1:2]
|
||||
if ch == "h" {
|
||||
continue
|
||||
}
|
||||
if cmd.Flags().ShorthandLookup(ch) == nil {
|
||||
return a
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func newCalendarCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/calendar.json
|
||||
// products.calendar). Catalog assembly stamps provenance contract_final.
|
||||
@@ -224,7 +43,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "calendar",
|
||||
Short: "日历日程 / 会议室 / 闲忙",
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
@@ -238,11 +57,11 @@ func newCalendarCommand() *cobra.Command {
|
||||
dws calendar book [list|get|search|update] 日历本管理
|
||||
dws calendar acl [list|add|delete] 日历访问权限管理`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── event: 日程 ─────────────────────────────────────────────
|
||||
|
||||
eventCmd := &cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE}
|
||||
eventCmd := newGroupCommand(&cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE})
|
||||
|
||||
eventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -816,13 +635,13 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
// ── attendee: 参会人 (曾用名: participant) ─────────────────
|
||||
|
||||
participantCmd := &cobra.Command{
|
||||
participantCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "attendee",
|
||||
Aliases: []string{"participant"},
|
||||
Short: "日程参会人管理",
|
||||
Long: "管理日程的参会人。alias:`participant`,仍作为别名保留,历史调用无需改动。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
participantListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -1008,7 +827,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
// ── room: 会议室 ────────────────────────────────────────────
|
||||
|
||||
roomCmd := &cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE}
|
||||
roomCmd := newGroupCommand(&cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE})
|
||||
|
||||
roomSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -1352,7 +1171,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
// ── busy: 闲忙 ──────────────────────────────────────────────
|
||||
|
||||
busyCmd := &cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE}
|
||||
busyCmd := newGroupCommand(&cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE})
|
||||
|
||||
busySearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -1444,7 +1263,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
// ── attachment: 附件 ────────────────────────────────────────
|
||||
|
||||
attachmentCmd := &cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE}
|
||||
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE})
|
||||
|
||||
attachmentAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
@@ -1529,7 +1348,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
// ── acl: 日历访问权限 ─────────────────────────────────────────
|
||||
|
||||
aclCmd := &cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE}
|
||||
aclCmd := newGroupCommand(&cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE})
|
||||
|
||||
aclListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -1619,7 +1438,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
// ── book: 日历本 ────────────────────────────────────────────
|
||||
|
||||
bookCmd := &cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE}
|
||||
bookCmd := newGroupCommand(&cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE})
|
||||
|
||||
bookListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -2657,12 +2476,6 @@ func newCalendarCommand() *cobra.Command {
|
||||
|
||||
root.AddCommand(eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd)
|
||||
|
||||
// Install the unknown-verb fallback on every group command. This covers
|
||||
// arbitrary typos like `dws calendar room query --min-duration 30` that
|
||||
// the per-verb calendarInfoHintSubCmd registrations below can't anticipate.
|
||||
for _, g := range []*cobra.Command{root, eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd} {
|
||||
installUnknownVerbFallback(g)
|
||||
}
|
||||
// Hint subcommands must swallow any extra flags/args the caller passes,
|
||||
// otherwise `dws calendar list` prints the nice "ambiguous command" hint
|
||||
// but `dws calendar list --start ...` fails earlier with cobra's
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -46,16 +47,13 @@ func TestCrossPlatformCoverageCalendarOptionalFlagsRemainingCoverage(t *testing.
|
||||
|
||||
func TestCrossPlatformCoverageCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) {
|
||||
root := &cobra.Command{Use: "calendar"}
|
||||
group := &cobra.Command{Use: "room"}
|
||||
group := newGroupCommand(&cobra.Command{Use: "room"})
|
||||
group.SuggestionsMinimumDistance = 3
|
||||
group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(group)
|
||||
installUnknownVerbFallback(group)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "calendar", "room", "--unknown"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
if err := group.RunE(group, nil); err != nil {
|
||||
t.Fatalf("unknown flag fallback: %v", err)
|
||||
err := group.RunE(group, []string{"serach"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" {
|
||||
t.Fatalf("typed suggestion fallback: %#v", err)
|
||||
}
|
||||
printUnknownSubcmdError(group, "serach")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -175,42 +176,22 @@ func TestCrossPlatformCoverageBuildRecurrenceCoverage(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) {
|
||||
root := &cobra.Command{Use: "calendar"}
|
||||
group := &cobra.Command{Use: "room", RunE: func(*cobra.Command, []string) error { return errors.New("previous") }}
|
||||
group := newGroupCommand(&cobra.Command{Use: "room"})
|
||||
known := &cobra.Command{Use: "search", Aliases: []string{"find"}}
|
||||
hidden := &cobra.Command{Use: "secret", Hidden: true}
|
||||
group.AddCommand(known, hidden)
|
||||
root.AddCommand(group)
|
||||
installUnknownVerbFallback(group)
|
||||
_ = group.RunE(group, []string{"unknown"})
|
||||
_ = group.RunE(group, []string{"--ignored", "search"})
|
||||
_ = group.RunE(group, nil)
|
||||
group.HelpFunc()(group, []string{"calendar", "room", "unknown"})
|
||||
group.HelpFunc()(known, nil)
|
||||
printUnknownSubcmdError(group, "searhc")
|
||||
printUnknownSubcmdError(group, "unrelated")
|
||||
var structured *apperrors.Error
|
||||
if err := group.RunE(group, []string{"searhc"}); !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" {
|
||||
t.Fatalf("typed group recovery = %#v", err)
|
||||
}
|
||||
if err := group.RunE(group, nil); err != nil {
|
||||
t.Fatalf("group help = %v", err)
|
||||
}
|
||||
|
||||
hint := calendarInfoHintSubCmd("query", "use search")
|
||||
group.AddCommand(hint)
|
||||
_ = hint.RunE(hint, nil)
|
||||
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
group.Flags().StringP("known", "k", "", "")
|
||||
for _, args := range [][]string{
|
||||
{"dws", "calendar", "room", "--"},
|
||||
{"dws", "calendar", "room", "--help"},
|
||||
{"dws", "calendar", "room", "--known=value"},
|
||||
{"dws", "calendar", "room", "--unknown=value"},
|
||||
{"dws", "calendar", "room", "-h"},
|
||||
{"dws", "calendar", "room", "-k", "value"},
|
||||
{"dws", "calendar", "room", "-x"},
|
||||
} {
|
||||
os.Args = args
|
||||
_ = findUnknownFlag(group)
|
||||
if err := hint.RunE(hint, nil); err == nil {
|
||||
t.Fatal("calendar compatibility hint succeeded")
|
||||
}
|
||||
nilPrev := &cobra.Command{Use: "empty"}
|
||||
root.AddCommand(nilPrev)
|
||||
installUnknownVerbFallback(nilPrev)
|
||||
os.Args = []string{"dws", "calendar", "empty"}
|
||||
_ = nilPrev.RunE(nilPrev, nil)
|
||||
}
|
||||
|
||||
+17
-16
@@ -2403,13 +2403,13 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "chat",
|
||||
Aliases: []string{"im"},
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
chatChmodCmd := &cobra.Command{
|
||||
Use: "chmod <scope>",
|
||||
@@ -2499,12 +2499,12 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
chatDataAuthCmd := &cobra.Command{
|
||||
chatDataAuthCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "data-auth",
|
||||
Short: "授予 chat 数据读取权限",
|
||||
Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
chatDataAuthCrossOrgCmd := &cobra.Command{
|
||||
Use: "cross-org",
|
||||
Short: "授予跨组织 chat 数据访问权限",
|
||||
@@ -2576,7 +2576,7 @@ func newChatCommand() *cobra.Command {
|
||||
|
||||
// ── group 子命令 ──────────────────────────────────────────
|
||||
|
||||
chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE}
|
||||
chatGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE})
|
||||
|
||||
chatGroupCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -2766,6 +2766,7 @@ func newChatCommand() *cobra.Command {
|
||||
return callMCPTool("get_group_members", toolArgs)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(chatGroupMembersCmd)
|
||||
|
||||
chatGroupMembersAddBotCmd := &cobra.Command{
|
||||
Use: "add-bot",
|
||||
@@ -2974,12 +2975,12 @@ func newChatCommand() *cobra.Command {
|
||||
|
||||
// ── message 子命令 ────────────────────────────────────────
|
||||
|
||||
chatMessageCmd := &cobra.Command{
|
||||
chatMessageCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "message",
|
||||
Short: "会话消息管理",
|
||||
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -4674,7 +4675,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
|
||||
// ── bot 子命令 ────────────────────────────────────────────
|
||||
|
||||
chatBotCmd := &cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE}
|
||||
chatBotCmd := newGroupCommand(&cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE})
|
||||
|
||||
chatBotSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -5210,12 +5211,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
|
||||
// ── file 子命令(会话文件上传,不暴露 spaceId)───────────────
|
||||
|
||||
chatFileCmd := &cobra.Command{
|
||||
chatFileCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "file",
|
||||
Short: "会话文件上传(已下线)",
|
||||
Hidden: true,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
chatFileUploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
@@ -5250,7 +5251,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
|
||||
// ── category 子命令(会话分组,走 IM MCP)───────────────────
|
||||
|
||||
chatCategoryCmd := &cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE}
|
||||
chatCategoryCmd := newGroupCommand(&cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE})
|
||||
|
||||
chatCategoryListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -7580,7 +7581,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
|
||||
// ── group-role 子命令(群身份管理)────────────────────────
|
||||
|
||||
chatGroupRoleCmd := &cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE}
|
||||
chatGroupRoleCmd := newGroupCommand(&cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE})
|
||||
|
||||
chatGroupRoleListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -9864,7 +9865,7 @@ status 可选值:
|
||||
})
|
||||
|
||||
// ── group notice: 群公告管理 ────────────────────────────────
|
||||
chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}
|
||||
chatGroupNoticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE})
|
||||
|
||||
chatGroupNoticeCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -10385,7 +10386,7 @@ status 可选值:
|
||||
"fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true,
|
||||
"he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true,
|
||||
}
|
||||
chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}
|
||||
chatTextCmd := newGroupCommand(&cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE})
|
||||
chatTextTranslateCmd := &cobra.Command{
|
||||
Use: "translate",
|
||||
Short: "翻译文本内容",
|
||||
@@ -10450,11 +10451,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd)
|
||||
|
||||
// ── chat group user-settings ──
|
||||
chatGroupUserSettingsCmd := &cobra.Command{
|
||||
chatGroupUserSettingsCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "user-settings",
|
||||
Short: "批量查询或更新当前用户的群会话设置",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
chatGroupUserSettingsQueryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: "批量查询当前用户的群会话设置",
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
|
||||
package chat
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newChatToolbarCommand() *cobra.Command {
|
||||
toolbarCmd := &cobra.Command{
|
||||
@@ -22,6 +25,11 @@ func newChatToolbarCommand() *cobra.Command {
|
||||
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(toolbarCmd, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
toolbarCmd.DisableAutoGenTag = true
|
||||
|
||||
toolbarCmd.AddCommand(
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -121,8 +122,12 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
err = root.ExecuteContext(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) {
|
||||
t.Fatalf("chat %s with legacy flags error = %T %v, want structured validation", tc.path, err, err)
|
||||
}
|
||||
if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
|
||||
t.Fatalf("chat %s with legacy flags error = %#v, want migration hint %q", tc.path, structured, tc.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command {
|
||||
},
|
||||
}
|
||||
media.AddCommand(newChatMediaUploadCommand())
|
||||
newHybridGroupCommand(media)
|
||||
return media
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
|
||||
func newChatEmotionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
cmd := newGroupCommand(&cobra.Command{
|
||||
Use: "emotion",
|
||||
Short: "个人收藏表情",
|
||||
Long: "查询、发送和新增当前用户的个人收藏表情。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
cmd.AddCommand(
|
||||
newChatEmotionListCommand(),
|
||||
newChatEmotionSendCommand(),
|
||||
|
||||
@@ -39,8 +39,9 @@ func newConferenceCommand() *cobra.Command {
|
||||
直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`,
|
||||
RunE: runUnavailable,
|
||||
}
|
||||
newHybridGroupCommand(root)
|
||||
|
||||
meetingCmd := &cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}
|
||||
meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE})
|
||||
|
||||
meetingCreateCmd := &cobra.Command{
|
||||
Use: "reserve",
|
||||
@@ -59,7 +60,7 @@ func newConferenceCommand() *cobra.Command {
|
||||
root.AddCommand(meetingCmd)
|
||||
|
||||
// member 子命令组 — 成员管理
|
||||
memberCmd := &cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE}
|
||||
memberCmd := newGroupCommand(&cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE})
|
||||
|
||||
memberInviteCmd := &cobra.Command{
|
||||
Use: "invite",
|
||||
|
||||
+17
-17
@@ -422,7 +422,7 @@ func newContactCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "contact",
|
||||
Short: "通讯录 / 用户 / 部门 / 角色 / 人员关系",
|
||||
Long: `查询钉钉通讯录:用户搜索、手机号查找、部门搜索、子部门 / 成员列表、人员关系;用户花名册档案信息(学历、家庭、银行卡、合同等)与离职员工信息。
|
||||
@@ -441,9 +441,9 @@ func newContactCommand() *cobra.Command {
|
||||
- contact user profile fields/get: 员工花名册档案查询(学历、家庭、银行卡等)
|
||||
- contact user dismission search: 离职员工列表查询`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
userCmd := &cobra.Command{
|
||||
userCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "user",
|
||||
Short: "人员管理",
|
||||
Long: `人员管理:通讯录用户查询、修改员工信息、邀请员工加入企业、用户档案(花名册)查询、离职员工查询。
|
||||
@@ -457,7 +457,7 @@ func newContactCommand() *cobra.Command {
|
||||
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
|
||||
- 查询离职员工列表 → contact user dismission search`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
contactUserGetSelfCmd := &cobra.Command{
|
||||
Use: "get-self",
|
||||
@@ -499,10 +499,10 @@ func newContactCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
relationCmd := &cobra.Command{Use: "relation",
|
||||
relationCmd := newGroupCommand(&cobra.Command{Use: "relation",
|
||||
Short: "人员关系查询",
|
||||
Long: `查询钉钉人员关系:特别关注人。`,
|
||||
RunE: groupRunE}
|
||||
RunE: groupRunE})
|
||||
|
||||
contactRelationListMyFollowingsCmd := &cobra.Command{
|
||||
Use: "list-my-followings",
|
||||
@@ -704,7 +704,7 @@ func newContactCommand() *cobra.Command {
|
||||
|
||||
// ── label 角色 ──────────────────────────────────────────────────
|
||||
|
||||
contactLabelCmd := &cobra.Command{
|
||||
contactLabelCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "label",
|
||||
Aliases: []string{"role"},
|
||||
Short: "角色查询",
|
||||
@@ -720,7 +720,7 @@ func newContactCommand() *cobra.Command {
|
||||
2. 从返回结果中匹配目标角色名称及 labelId
|
||||
3. contact label list-members --id <labelId> → 获取该角色下的成员`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
runContactLabelList := func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
@@ -796,7 +796,7 @@ func newContactCommand() *cobra.Command {
|
||||
|
||||
contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd)
|
||||
|
||||
contactDeptCmd := &cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE}
|
||||
contactDeptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE})
|
||||
|
||||
contactDeptSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -988,7 +988,7 @@ func newContactCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
// ── user profile 用户档案(花名册) ────────────────────────────────────
|
||||
contactUserProfileCmd := &cobra.Command{
|
||||
contactUserProfileCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "用户档案(花名册)",
|
||||
Long: `用户档案(花名册):查询花名册字段列表、查询员工花名册字段信息。
|
||||
@@ -1000,7 +1000,7 @@ func newContactCommand() *cobra.Command {
|
||||
- contact user get: 组织管理信息(部门、主管、管理员权限)
|
||||
- contact user profile get: 个人档案信息(学历、家庭、银行卡等)`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
contactUserProfileFieldsCmd := &cobra.Command{
|
||||
Use: "fields",
|
||||
@@ -1124,12 +1124,12 @@ contact user profile fields 获取可用字段列表。
|
||||
contactUserProfileCmd.AddCommand(contactUserProfileFieldsCmd, contactUserProfileGetCmd)
|
||||
|
||||
// ── user dismission 离职员工 ───────────────────────────────────────────
|
||||
contactUserDismissionCmd := &cobra.Command{
|
||||
contactUserDismissionCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "dismission",
|
||||
Short: "离职员工查询",
|
||||
Long: `离职员工查询:分页获取离职员工列表,支持按员工姓名、离职时间范围、部门进行过滤。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
contactUserDismissionSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -1568,7 +1568,7 @@ contact user profile fields 获取可用字段列表。
|
||||
|
||||
// ── org 企业管理 ──────────────────────────────────────────────────
|
||||
|
||||
contactOrgCmd := &cobra.Command{
|
||||
contactOrgCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "org",
|
||||
Short: "企业管理",
|
||||
Long: `企业管理:创建企业。
|
||||
@@ -1578,7 +1578,7 @@ contact user profile fields 获取可用字段列表。
|
||||
- 创建企业专属账号 → contact account create
|
||||
- 邀请员工加入企业 → contact user invite`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
contactOrgCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -1639,12 +1639,12 @@ contact user profile fields 获取可用字段列表。
|
||||
|
||||
// ── account 企业账号管理 ──────────────────────────────────────────
|
||||
|
||||
contactAccountCmd := &cobra.Command{
|
||||
contactAccountCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "account",
|
||||
Short: "企业账号管理",
|
||||
Long: "企业账号管理:创建或更新企业专属账号。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
contactAccountCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -37,8 +39,11 @@ func TestCrossPlatformCoverageContactRemainingCompatibilityBranches(t *testing.T
|
||||
if err := hint.RunE(hint, []string{"--help"}); err != nil {
|
||||
t.Fatalf("hint help: %v", err)
|
||||
}
|
||||
if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") {
|
||||
t.Fatalf("hint guidance err=%v", err)
|
||||
err := hint.RunE(hint, []string{"unexpected"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
|
||||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "use: dws contact dept") {
|
||||
t.Fatalf("hint guidance err=%#v", structured)
|
||||
}
|
||||
|
||||
if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") {
|
||||
|
||||
@@ -5,11 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -30,30 +30,6 @@ func TestCrossPlatformCoveragePureScalarAndCommandHelpersCoverage(t *testing.T)
|
||||
_ = isNumericUserID(value)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "calendar"}
|
||||
known := &cobra.Command{Use: "event", Aliases: []string{"e"}}
|
||||
hidden := &cobra.Command{Use: "hidden", Hidden: true}
|
||||
root.AddCommand(known, hidden)
|
||||
for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} {
|
||||
_ = findUnknownVerb(root, args)
|
||||
}
|
||||
printUnknownSubcmdError(root, "evnt")
|
||||
for _, depth := range []int{0, 1, 3} {
|
||||
_ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth)
|
||||
}
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root.Flags().StringP("known", "k", "", "")
|
||||
for _, args := range [][]string{
|
||||
{"dws", "calendar", "--known=x"},
|
||||
{"dws", "calendar", "--unknown"},
|
||||
{"dws", "calendar", "-z"},
|
||||
{"dws", "calendar", "--", "--ignored"},
|
||||
} {
|
||||
os.Args = args
|
||||
_ = findUnknownFlag(root)
|
||||
}
|
||||
|
||||
for _, event := range []any{
|
||||
nil,
|
||||
map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}},
|
||||
@@ -284,7 +260,7 @@ func TestCrossPlatformCoverageSmallHandlerAndFormatterCoverage(t *testing.T) {
|
||||
group := &cobra.Command{Use: "range"}
|
||||
group.AddCommand(&cobra.Command{Use: "read"})
|
||||
parent.AddCommand(group)
|
||||
_ = deepSuggestSubcommand(parent, "read")
|
||||
_ = deepSuggestSubcommand(parent, "missing")
|
||||
_ = cmdutil.SuggestDescendantSubcommands(parent, "read")
|
||||
_ = cmdutil.SuggestDescendantSubcommands(parent, "missing")
|
||||
_ = time.Now()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ package helpers
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -71,7 +70,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
newGroupCommand(root)
|
||||
|
||||
doc := &cobra.Command{
|
||||
Use: "doc",
|
||||
@@ -83,7 +82,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(doc)
|
||||
newGroupCommand(doc)
|
||||
doc.AddCommand(newDevDocSearchCommand(runner))
|
||||
|
||||
root.AddCommand(
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -140,7 +139,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
newGroupCommand(root)
|
||||
|
||||
webapp := &cobra.Command{
|
||||
Use: "webapp",
|
||||
@@ -152,7 +151,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(webapp)
|
||||
newGroupCommand(webapp)
|
||||
webapp.AddCommand(
|
||||
newDevAppWebappGetCommand(runner),
|
||||
newDevAppWebappConfigCommand(runner),
|
||||
@@ -168,7 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(permission)
|
||||
newGroupCommand(permission)
|
||||
permission.AddCommand(
|
||||
newDevAppPermissionListCommand(runner),
|
||||
newDevAppPermissionAddCommand(runner),
|
||||
@@ -185,7 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(credentials)
|
||||
newGroupCommand(credentials)
|
||||
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
|
||||
|
||||
member := &cobra.Command{
|
||||
@@ -198,7 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(member)
|
||||
newGroupCommand(member)
|
||||
member.AddCommand(
|
||||
newDevAppMemberListCommand(runner),
|
||||
newDevAppMemberAddCommand(runner),
|
||||
@@ -215,7 +214,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(security)
|
||||
newGroupCommand(security)
|
||||
security.AddCommand(newDevAppSecurityConfigCommand(runner))
|
||||
|
||||
robot := &cobra.Command{
|
||||
@@ -228,7 +227,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(robot)
|
||||
newGroupCommand(robot)
|
||||
robot.AddCommand(
|
||||
newDevAppRobotSubmitCommand(runner),
|
||||
newDevAppRobotResultCommand(runner),
|
||||
@@ -248,7 +247,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(version)
|
||||
newGroupCommand(version)
|
||||
version.AddCommand(
|
||||
newDevAppVersionCreateCommand(runner),
|
||||
newDevAppVersionListCommand(runner),
|
||||
@@ -268,7 +267,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(event)
|
||||
newGroupCommand(event)
|
||||
event.AddCommand(
|
||||
newDevAppEventListCommand(runner),
|
||||
newDevAppEventSubscribeCommand(runner),
|
||||
|
||||
@@ -587,6 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
newDevAppRobotConnectRestartCommand(),
|
||||
newDevAppRobotConnectListCommand(runner),
|
||||
)
|
||||
newHybridGroupCommand(cmd)
|
||||
cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)")
|
||||
cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD")
|
||||
// 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖
|
||||
|
||||
@@ -24,14 +24,14 @@ func newDevdocCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "devdoc",
|
||||
Short: "开放平台文档搜索",
|
||||
Long: `搜索钉钉开放平台开发文档。默认以表格格式输出(标题、URL),使用 -f json 获取原始 JSON。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
articleCmd := &cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE}
|
||||
articleCmd := newGroupCommand(&cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE})
|
||||
articleCmd.AddCommand(newDevdocArticleSearchCommand())
|
||||
root.AddCommand(articleCmd)
|
||||
root.AddCommand(hintSubCmd("search", "use: dws devdoc article search --query <关键词>"))
|
||||
|
||||
@@ -42,14 +42,14 @@ func newDingCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "ding",
|
||||
Short: "DING 消息 / 发送 / 撤回",
|
||||
Long: `发送和撤回 DING 消息(应用内/短信/电话)。预发环境可用。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
dingMessageCmd := &cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE}
|
||||
dingMessageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE})
|
||||
|
||||
dingMessageSendCmd := &cobra.Command{
|
||||
Use: "send",
|
||||
|
||||
+17
-15
@@ -1166,7 +1166,7 @@ func newDocCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "doc",
|
||||
Short: "钉钉文档管理",
|
||||
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
|
||||
@@ -1190,7 +1190,7 @@ func newDocCommand() *cobra.Command {
|
||||
|
||||
文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
searchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -1826,7 +1826,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
},
|
||||
})
|
||||
|
||||
fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}
|
||||
fileCmd := newGroupCommand(&cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE})
|
||||
|
||||
fileCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -1904,7 +1904,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
},
|
||||
})
|
||||
|
||||
folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}
|
||||
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE})
|
||||
|
||||
folderCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -2063,12 +2063,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
},
|
||||
})
|
||||
|
||||
blockCmd := &cobra.Command{
|
||||
blockCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "block",
|
||||
Short: "块级编辑",
|
||||
Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
blockListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -2844,12 +2844,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
_ = renameCmd.Flags().MarkHidden("title")
|
||||
|
||||
// ── media (文档媒体/附件) ────────────────────────────────
|
||||
mediaCmd := &cobra.Command{
|
||||
mediaCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "media",
|
||||
Short: "文档媒体 / 附件管理",
|
||||
Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
mediaDownloadCmd := &cobra.Command{
|
||||
Use: "download",
|
||||
@@ -3036,12 +3036,12 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
|
||||
|
||||
// ── comment (文档评论) ──────────────────────────────────
|
||||
commentCmd := &cobra.Command{
|
||||
commentCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "comment",
|
||||
Short: "文档评论 / 评论管理",
|
||||
Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
commentListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3545,13 +3545,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
|
||||
|
||||
// ── permission (文档协作权限) ────────────────────────────
|
||||
permissionCmd := &cobra.Command{
|
||||
permissionCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "permission",
|
||||
Aliases: []string{"perm"},
|
||||
Short: "文档协作权限管理",
|
||||
Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
permissionAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
@@ -4260,6 +4260,7 @@ CLI 内部自动完成全部流程:
|
||||
_ = exportCmd.Flags().MarkHidden("file-id")
|
||||
|
||||
exportCmd.AddCommand(exportGetCmd)
|
||||
newHybridGroupCommand(exportCmd)
|
||||
|
||||
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
|
||||
importCmd := &cobra.Command{
|
||||
@@ -4356,14 +4357,15 @@ CLI 内部自动完成全部流程:
|
||||
})
|
||||
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
|
||||
importCmd.AddCommand(importGetCmd)
|
||||
newHybridGroupCommand(importCmd)
|
||||
|
||||
// ── doc version 子命令组 ──
|
||||
versionCmd := &cobra.Command{
|
||||
versionCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "version",
|
||||
Short: "文档历史版本管理",
|
||||
Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
versionSaveCmd := &cobra.Command{
|
||||
Use: "save",
|
||||
@@ -4549,7 +4551,7 @@ CLI 内部自动完成全部流程:
|
||||
versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd)
|
||||
|
||||
// ── template 子命令组 ──────────────────────────────────────────────────────
|
||||
templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}
|
||||
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE})
|
||||
|
||||
templateListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
@@ -20,7 +20,7 @@ const docStyleGetToolName = "get_document_style"
|
||||
|
||||
// newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。
|
||||
func newDocStyleCommand() *cobra.Command {
|
||||
styleCmd := &cobra.Command{
|
||||
styleCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "style",
|
||||
Short: "文档样式配置 (封面/背景)",
|
||||
Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。
|
||||
@@ -34,13 +34,13 @@ func newDocStyleCommand() *cobra.Command {
|
||||
|
||||
封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
coverCmd := &cobra.Command{
|
||||
coverCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "cover",
|
||||
Short: "文档封面设置/移除",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
coverSetCmd := &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "设置文档封面",
|
||||
@@ -110,11 +110,11 @@ func newDocStyleCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
backgroundCmd := &cobra.Command{
|
||||
backgroundCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "background",
|
||||
Short: "文档背景设置/清除",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
backgroundSetCmd := &cobra.Command{
|
||||
Use: "set",
|
||||
Short: "设置文档背景纯色",
|
||||
|
||||
@@ -207,12 +207,12 @@ func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
func newDocWhiteboardCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "whiteboard",
|
||||
Short: "白板卡片管理",
|
||||
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
insertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
|
||||
+11
-11
@@ -375,12 +375,12 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
driveCmd := &cobra.Command{
|
||||
driveCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "钉盘文件管理",
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
driveListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -2030,14 +2030,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
|
||||
|
||||
// ── drive permission (文档节点权限管理) ──
|
||||
drivePermissionCmd := &cobra.Command{
|
||||
drivePermissionCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "permission",
|
||||
Aliases: []string{"perm"},
|
||||
Short: "文档节点权限管理",
|
||||
Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。
|
||||
注意: 仅适用于文档空间节点,不适用于钉盘文件。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
drivePermAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
@@ -2806,12 +2806,12 @@ func newDriveCommand() *cobra.Command {
|
||||
_ = driveRenameCmd.Flags().MarkHidden("title")
|
||||
|
||||
// ── drive recycle 子命令组 ──
|
||||
recycleCmd := &cobra.Command{
|
||||
recycleCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "recycle",
|
||||
Short: "钉盘回收站管理",
|
||||
Long: `管理钉盘回收站:查看回收站列表、还原回收项。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
recycleListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -2921,7 +2921,7 @@ func newDriveCommand() *cobra.Command {
|
||||
// ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)──
|
||||
|
||||
// folder create → dws wiki node create --type folder
|
||||
driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE}
|
||||
driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE})
|
||||
driveFolderCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建文件夹(deprecated)",
|
||||
@@ -2951,12 +2951,12 @@ func newDriveCommand() *cobra.Command {
|
||||
driveFolderCmd.AddCommand(driveFolderCreateCmd)
|
||||
|
||||
// ── drive publish (文件互联网公开发布管理) ──
|
||||
drivePublishCmd := &cobra.Command{
|
||||
drivePublishCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "publish",
|
||||
Short: "文件互联网公开发布管理",
|
||||
Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
drivePublishSetCmd := &cobra.Command{
|
||||
Use: "set",
|
||||
@@ -3251,11 +3251,11 @@ func newDriveCommand() *cobra.Command {
|
||||
_ = driveRecentCmd.Flags().MarkHidden("page-token")
|
||||
|
||||
// ── drive star (文档收藏管理) ──
|
||||
driveStarCmd := &cobra.Command{
|
||||
driveStarCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "star",
|
||||
Short: "文档收藏管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
driveStarAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "收藏文档",
|
||||
|
||||
@@ -55,12 +55,12 @@ func fileCommentSpaceIDFlag() LeafFlag {
|
||||
// doc comment, sheet comment, and drive comment. The public command belongs to
|
||||
// Drive, while the implementation routes to the shared doc-comment MCP server.
|
||||
func newDriveFileCommentCmd() *cobra.Command {
|
||||
commentCmd := &cobra.Command{
|
||||
commentCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "comment",
|
||||
Short: "普通文件评论管理",
|
||||
Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
listCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "list",
|
||||
|
||||
@@ -15,15 +15,16 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// Re-export cmdutil functions as package-level aliases so that existing product
|
||||
// files continue to compile with their current (unexported) call sites.
|
||||
// This avoids a mass-rename in 22 product files while still consolidating the
|
||||
// implementations in pkg/cmdutil.
|
||||
// Re-export shared command helpers as package-level aliases so existing product
|
||||
// files continue to compile with their current (unexported) call sites. This
|
||||
// avoids a mass-rename while keeping reusable command-resolution and flag
|
||||
// utilities in cmdutil.
|
||||
var (
|
||||
groupRunE = cmdutil.GroupRunE
|
||||
hintSubCmd = cmdutil.HintSubCmd
|
||||
@@ -38,6 +39,40 @@ var (
|
||||
helperAfter = time.After
|
||||
)
|
||||
|
||||
// newGroupCommand declares the ordinary navigation policy used by helper
|
||||
// command containers. The unified framework compiles this declaration into
|
||||
// Cobra behavior and command-resolution metadata.
|
||||
func newGroupCommand(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return command
|
||||
}
|
||||
|
||||
// newDeepGroupCommand declares a navigation container whose typo recovery may
|
||||
// teach exact descendant paths (for example sheet read -> sheet range read).
|
||||
func newDeepGroupCommand(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
return command
|
||||
}
|
||||
|
||||
// newHybridGroupCommand declares a business command that also owns children.
|
||||
// Its existing RunE remains the command's default action.
|
||||
func newHybridGroupCommand(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return command
|
||||
}
|
||||
|
||||
// Deps holds shared dependencies injected from the host application.
|
||||
type Deps struct {
|
||||
Caller edition.ToolCaller
|
||||
|
||||
@@ -29,7 +29,7 @@ func newHrbrainCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "hrbrain",
|
||||
Short: "组织大脑:人才池、员工档案与人才搜索",
|
||||
Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。
|
||||
@@ -47,11 +47,11 @@ func newHrbrainCommand() *cobra.Command {
|
||||
dws hrbrain search employees-structured 使用高级条件搜索人员
|
||||
dws hrbrain search fields 获取高级搜索字段列表`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── talent-pool: 人才池管理 ────────────────────────────────
|
||||
|
||||
talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}
|
||||
talentPoolCmd := newGroupCommand(&cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE})
|
||||
|
||||
talentPoolListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -236,7 +236,7 @@ func newHrbrainCommand() *cobra.Command {
|
||||
|
||||
// ── profile: 员工档案管理 ──────────────────────────────────
|
||||
|
||||
profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}
|
||||
profileCmd := newGroupCommand(&cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE})
|
||||
|
||||
profileMetadataCmd := &cobra.Command{
|
||||
Use: "metadata",
|
||||
@@ -493,7 +493,7 @@ func newHrbrainCommand() *cobra.Command {
|
||||
|
||||
// ── search: 人才搜索 ─────────────────────────────────────
|
||||
|
||||
searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}
|
||||
searchCmd := newGroupCommand(&cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE})
|
||||
|
||||
employeeSearchCmd := &cobra.Command{
|
||||
Use: "employees",
|
||||
|
||||
@@ -72,7 +72,8 @@ func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command
|
||||
out := make([]*cobra.Command, 0, len(factories))
|
||||
for _, factory := range factories {
|
||||
handler := factory()
|
||||
out = append(out, handler.Command(runner))
|
||||
command := handler.Command(runner)
|
||||
out = append(out, command)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].Use < out[j].Use
|
||||
|
||||
@@ -20,14 +20,14 @@ func newLiveCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "live",
|
||||
Short: "直播列表 / 信息",
|
||||
Long: `查看钉钉直播:列出我的直播记录。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
streamCmd := &cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE}
|
||||
streamCmd := newGroupCommand(&cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE})
|
||||
|
||||
streamListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
+19
-19
@@ -113,14 +113,14 @@ func newMailCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "mail",
|
||||
Short: "邮箱 / 邮件收发",
|
||||
Long: `管理钉钉企业邮箱:查询邮箱地址、搜索邮件、查看邮件、发送邮件、获取会话(thread)、列举文件夹、列举标签。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
mailboxCmd := &cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE}
|
||||
mailboxCmd := newGroupCommand(&cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE})
|
||||
|
||||
mailboxListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -263,7 +263,7 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE})
|
||||
|
||||
messageSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -588,7 +588,7 @@ func newMailCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
folderCmd := &cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE}
|
||||
folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE})
|
||||
|
||||
folderListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -842,7 +842,7 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
folderCmd.AddCommand(folderListCmd, folderCreateCmd, folderDeleteCmd, folderUpdateCmd)
|
||||
|
||||
tagCmd := &cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE}
|
||||
tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE})
|
||||
|
||||
tagListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -995,7 +995,7 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
tagCmd.AddCommand(tagListCmd, tagCreateCmd, tagDeleteCmd, tagUpdateCmd)
|
||||
|
||||
threadCmd := &cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE}
|
||||
threadCmd := newGroupCommand(&cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE})
|
||||
|
||||
threadListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -1975,7 +1975,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
},
|
||||
})
|
||||
|
||||
attachmentCmd := &cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE}
|
||||
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE})
|
||||
|
||||
attachmentListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -2443,7 +2443,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
sentMessageCmd := newGroupCommand(&cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE})
|
||||
|
||||
sentMessageRecallCmd := &cobra.Command{
|
||||
Use: "recall",
|
||||
@@ -2600,10 +2600,10 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
_ = draftSendCmd.Flags().MarkHidden("sender")
|
||||
draftSendCmd.Flags().String("id", "", "草稿邮件 ID (必填)")
|
||||
|
||||
draftCmd := &cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE}
|
||||
draftCmd := newGroupCommand(&cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE})
|
||||
draftCmd.AddCommand(draftCreateCmd, draftUpdateCmd, draftSendCmd)
|
||||
|
||||
userCmd := &cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE}
|
||||
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE})
|
||||
|
||||
userSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -2707,7 +2707,7 @@ user 对象字段:
|
||||
|
||||
// ── template 子命令组 ──────────────────────────────────
|
||||
|
||||
templateCmd := &cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE}
|
||||
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE})
|
||||
|
||||
templateCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -3057,7 +3057,7 @@ user 对象字段:
|
||||
|
||||
// ── contact 子命令组 ──────────────────────────────────
|
||||
|
||||
contactCmd := &cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE}
|
||||
contactCmd := newGroupCommand(&cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE})
|
||||
|
||||
contactCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -3315,7 +3315,7 @@ user 对象字段:
|
||||
contactCmd.AddCommand(contactCreateCmd, contactListCmd, contactUpdateCmd, contactBatchDeleteCmd)
|
||||
|
||||
// ── auto-reply 自动回复 ──────────────────────────────
|
||||
autoReplyCmd := &cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE}
|
||||
autoReplyCmd := newGroupCommand(&cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE})
|
||||
|
||||
autoReplyGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -3393,7 +3393,7 @@ user 对象字段:
|
||||
autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd)
|
||||
|
||||
// ── rule 收信规则 ────────────────────────────────────
|
||||
ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}
|
||||
ruleCmd := newGroupCommand(&cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE})
|
||||
|
||||
ruleListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3578,7 +3578,7 @@ object 与 operation 合法组合:
|
||||
ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd)
|
||||
|
||||
// ── allow-list 个人收信白名单 ────────────────────────────────
|
||||
allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}
|
||||
allowListCmd := newGroupCommand(&cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE})
|
||||
|
||||
allowListListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3646,7 +3646,7 @@ object 与 operation 合法组合:
|
||||
allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd)
|
||||
|
||||
// ── block-list 个人收信黑名单 ────────────────────────────────
|
||||
blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}
|
||||
blockListCmd := newGroupCommand(&cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE})
|
||||
|
||||
blockListListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3713,7 +3713,7 @@ object 与 operation 合法组合:
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
|
||||
calendarCmd := newGroupCommand(&cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE})
|
||||
calendarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出用户可访问的日历列表",
|
||||
@@ -3762,7 +3762,7 @@ object 与 operation 合法组合:
|
||||
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarCmd.AddCommand(calendarListCmd)
|
||||
|
||||
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
|
||||
calendarEventCmd := newGroupCommand(&cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE})
|
||||
calendarEventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询指定日历时间范围内的日程",
|
||||
|
||||
@@ -46,12 +46,12 @@ func newMarkdownCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Markdown 文件处理",
|
||||
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
root.AddCommand(
|
||||
newMarkdownFetchCmd(),
|
||||
newMarkdownCreateCmd(),
|
||||
|
||||
+14
-14
@@ -28,7 +28,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
minutesListCmd := &cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE}
|
||||
minutesListCmd := newGroupCommand(&cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE})
|
||||
|
||||
minutesListMineCmd := &cobra.Command{
|
||||
Use: "mine",
|
||||
@@ -173,7 +173,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
minutesGetCmd := &cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE}
|
||||
minutesGetCmd := newGroupCommand(&cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE})
|
||||
|
||||
minutesGetInfoCmd := &cobra.Command{
|
||||
Use: "info",
|
||||
@@ -544,7 +544,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
minutesUpdateCmd := &cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE}
|
||||
minutesUpdateCmd := newGroupCommand(&cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE})
|
||||
|
||||
minutesUpdateTitleCmd := &cobra.Command{
|
||||
Use: "title",
|
||||
@@ -659,7 +659,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
// 单个工具通过 cmd 参数覆盖 create/pause/resume/end 四种指令。
|
||||
const listeningNoteCmdTool = "执行听记指令-发起AI听记录音"
|
||||
|
||||
minutesRecordCmd := &cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE}
|
||||
minutesRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE})
|
||||
|
||||
minutesRecordStartCmd := &cobra.Command{
|
||||
Use: "start",
|
||||
@@ -942,7 +942,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
minutesUpdateCmd.AddCommand(minutesUpdateTitleCmd, minutesUpdateSummaryCmd)
|
||||
|
||||
// ── mind-graph 子组 ─────────────────────────────────────────
|
||||
mindGraphCmd := &cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE}
|
||||
mindGraphCmd := newGroupCommand(&cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE})
|
||||
|
||||
mindGraphCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -1059,7 +1059,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
mindGraphCmd.AddCommand(mindGraphCreateCmd, mindGraphStatusCmd)
|
||||
|
||||
// ── speaker 子组 ────────────────────────────────────────────
|
||||
speakerCmd := &cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE}
|
||||
speakerCmd := newGroupCommand(&cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE})
|
||||
|
||||
speakerReplaceCmd := &cobra.Command{
|
||||
Use: "replace",
|
||||
@@ -1127,7 +1127,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
// 对应 MCP 工具 create_speaker_summary / get_speaker_summary
|
||||
// 批量按听记维度汇总每位发言人的段落总结
|
||||
|
||||
speakerSummaryCmd := &cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE}
|
||||
speakerSummaryCmd := newGroupCommand(&cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE})
|
||||
|
||||
speakerSummaryCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -1243,7 +1243,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
speakerCmd.AddCommand(speakerReplaceCmd, speakerSummaryCmd)
|
||||
|
||||
// ── hot-word 子组 ───────────────────────────────────────────
|
||||
hotWordCmd := &cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE}
|
||||
hotWordCmd := newGroupCommand(&cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE})
|
||||
|
||||
hotWordAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
@@ -1462,7 +1462,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
// 完整流程:create → HTTP PUT → complete,或 create → cancel 取消。
|
||||
// 注意:upload 子组的所有命令均使用 callMCPToolUnescaped 输出 JSON,
|
||||
// 避免 presignedUrl 中的 & 被 Go 标准库转义为 \u0026。
|
||||
uploadCmd := &cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE}
|
||||
uploadCmd := newGroupCommand(&cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE})
|
||||
|
||||
// upload create — 对应 MCP 工具 create_upload_session
|
||||
// 必填参数:fileName(--file-name), fileSize(--file-size)
|
||||
@@ -1681,7 +1681,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
// ── permission 子组 ─────────────────────────────────────────
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
|
||||
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
|
||||
permissionCmd := newGroupCommand(&cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE})
|
||||
|
||||
// permission add — 对应 MCP 工具 add_member_permission
|
||||
// 批量给多个听记增加成员,并设置成员的权限。
|
||||
@@ -1951,7 +1951,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
|
||||
// 对应 MCP 工具 query_user_tag_list / query_minutes_by_tag_id。
|
||||
// 标签/分组由用户在听记页面手动创建,此处仅提供查询能力。
|
||||
tagCmd := &cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE}
|
||||
tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE})
|
||||
|
||||
// tag list — 对应 MCP 工具 query_user_tag_list
|
||||
// 无需传入参数,系统自动识别当前用户身份。
|
||||
@@ -2075,7 +2075,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
|
||||
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
|
||||
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
|
||||
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
|
||||
audioMemoCmd := newGroupCommand(&cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE})
|
||||
|
||||
audioMemoListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -2179,12 +2179,12 @@ func newMinutesCommand() *cobra.Command {
|
||||
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
|
||||
audioMemoCmd.AddCommand(audioMemoListCmd)
|
||||
|
||||
minutesCmd := &cobra.Command{
|
||||
minutesCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: "AI 听记 / 会议纪要",
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
return minutesCmd
|
||||
}
|
||||
|
||||
@@ -369,11 +369,11 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
func newOAAttachmentCommand() *cobra.Command {
|
||||
attachmentCmd := &cobra.Command{
|
||||
attachmentCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "attachment",
|
||||
Short: "审批附件授权、上传、下载与链接管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
downloadURLCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "download-url",
|
||||
@@ -724,14 +724,14 @@ func newOaCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "oa",
|
||||
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
|
||||
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
approvalCmd := &cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE}
|
||||
approvalCmd := newGroupCommand(&cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE})
|
||||
|
||||
approvalListPendingCmd := &cobra.Command{
|
||||
Use: "list-pending",
|
||||
|
||||
@@ -61,17 +61,17 @@ func newRecruitCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "recruit",
|
||||
Short: "钉钉招聘",
|
||||
Long: "查询和创建钉钉招聘中的职位信息。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
job := &cobra.Command{
|
||||
})
|
||||
job := newGroupCommand(&cobra.Command{
|
||||
Use: "job",
|
||||
Short: "招聘职位管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
job.AddCommand(
|
||||
newRecruitJobListCommand(),
|
||||
newRecruitJobGetCommand(),
|
||||
|
||||
@@ -64,7 +64,7 @@ func newReportCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "report",
|
||||
Aliases: []string{"log"},
|
||||
Short: "钉钉日志(OA 周报应用 / 日志模版填报)",
|
||||
@@ -84,10 +84,10 @@ func newReportCommand() *cobra.Command {
|
||||
|
||||
别名:dws log 等价 dws report(注意:此处 log 特指 OA 周报应用,不是通用日志/记录)。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// === template subtree(template list 不变;新增 template get;template detail 转 deprecated alias)===
|
||||
templateCmd := &cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE}
|
||||
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE})
|
||||
|
||||
templateListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -179,7 +179,7 @@ func newReportCommand() *cobra.Command {
|
||||
templateCmd.AddCommand(templateListCmd, templateGetCmd, templateDetailCmd)
|
||||
|
||||
// === entry subtree(单条日报操作 — get / stats / submit)===
|
||||
entryCmd := &cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE}
|
||||
entryCmd := newGroupCommand(&cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE})
|
||||
|
||||
entryGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -390,9 +390,10 @@ func newReportCommand() *cobra.Command {
|
||||
addReportListFlags(inboxListCmd)
|
||||
|
||||
inboxCmd.AddCommand(inboxListCmd)
|
||||
newHybridGroupCommand(inboxCmd)
|
||||
|
||||
// === outbox subtree(我发出的日报)===
|
||||
outboxCmd := &cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE}
|
||||
outboxCmd := newGroupCommand(&cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE})
|
||||
|
||||
outboxListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
@@ -38,7 +38,7 @@ func newSheetCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newDeepGroupCommand(&cobra.Command{
|
||||
Use: "sheet",
|
||||
Short: "钉钉表格管理",
|
||||
Long: `管理钉钉在线电子表格:创建表格、工作表管理、数据读写、单元格搜索、查找替换、单元格合并与取消合并、行列插入删除移动追加与属性更新、附件上传、浮动图片管理、筛选视图管理、下拉列表管理。
|
||||
@@ -119,7 +119,7 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet template list 获取表格模板列表
|
||||
dws sheet template search 搜索表格模板
|
||||
dws sheet template apply 应用表格模板创建新表格文档`,
|
||||
}
|
||||
})
|
||||
|
||||
// ── Build commands via factory functions ──────────────────────────
|
||||
workbookCmds := newWorkbookCmds()
|
||||
@@ -296,15 +296,6 @@ func newSheetCommand() *cobra.Command {
|
||||
attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint)
|
||||
}
|
||||
|
||||
// Guards for grouped parent commands
|
||||
attachUnknownSubcommandGuard(root)
|
||||
attachUnknownSubcommandGuard(rangeCmd)
|
||||
attachUnknownSubcommandGuard(filterCmd)
|
||||
attachUnknownSubcommandGuard(filterViewCmd)
|
||||
attachUnknownSubcommandGuard(condFormatCmd)
|
||||
attachUnknownSubcommandGuard(chartCmd)
|
||||
attachUnknownSubcommandGuard(pivotTableCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -315,73 +306,3 @@ func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHi
|
||||
}
|
||||
protectSheetMutationCommand(command, operation, targetHint)
|
||||
}
|
||||
|
||||
// attachUnknownSubcommandGuard 为分组型命令挂上拼错子命令时的 did-you-mean 提示。
|
||||
//
|
||||
// 背景:cobra 对父命令的 Args 校验发生在 ParseFlags 之后,而 pflag 默认把未知 flag
|
||||
// 当作硬错误抛出。于是 `dws sheet read --sheet-id X` 会先报 `unknown flag: --sheet-id`,
|
||||
// 真正的根因(read 不是 sheet 的直接子命令)被彻底掩盖;同时 `dws sheet reead` 会被
|
||||
// 当成位置参数静默吞掉、打印 help 后 exit=0,AI Agent 无法察觉命令执行失败。
|
||||
//
|
||||
// 本函数通过三件套让分组命令在"没匹配到子命令"时给出明确的错误与建议:
|
||||
// 1. FParseErrWhitelist.UnknownFlags=true —— pflag 不再因未知 flag 中断,
|
||||
// 未知 flag 连同其值一起被静默消化;
|
||||
// 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理;
|
||||
// 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子
|
||||
// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回 cobra
|
||||
// 自带的同级编辑距离建议;最终返回 error 以保证 exit!=0。
|
||||
//
|
||||
// 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段
|
||||
// 精确匹配到的合法叶子命令。
|
||||
func attachUnknownSubcommandGuard(cmd *cobra.Command) {
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
cmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true}
|
||||
cmd.SilenceUsage = true
|
||||
// cobra 仅在 root 自动把 SuggestionsMinimumDistance 兑成 2,子命令默认为 0,
|
||||
// 会导致 `sheet range reead` 这样的同级近似拼写无法触发内置建议。
|
||||
if cmd.SuggestionsMinimumDistance <= 0 {
|
||||
cmd.SuggestionsMinimumDistance = 2
|
||||
}
|
||||
cmd.RunE = func(c *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return c.Help()
|
||||
}
|
||||
name := args[0]
|
||||
var buf strings.Builder
|
||||
fmt.Fprintf(&buf, "unknown command %q for %q", name, c.CommandPath())
|
||||
suggestions := deepSuggestSubcommand(c, name)
|
||||
if len(suggestions) == 0 {
|
||||
suggestions = c.SuggestionsFor(name)
|
||||
}
|
||||
if len(suggestions) > 0 {
|
||||
buf.WriteString("\n\nDid you mean this?")
|
||||
for _, s := range suggestions {
|
||||
fmt.Fprintf(&buf, "\n\t%s %s", c.CommandPath(), s)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&buf, "\n\nRun '%s --help' for usage.", c.CommandPath())
|
||||
return fmt.Errorf("%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// deepSuggestSubcommand 在所有后代命令里查找与 name 完全同名的可用子命令,
|
||||
// 返回从 parent 出发的相对路径列表。用于把 `sheet read` 这样的平铺习惯引导到
|
||||
// 真实的深路径 `sheet range read`。
|
||||
func deepSuggestSubcommand(parent *cobra.Command, name string) []string {
|
||||
var out []string
|
||||
var walk func(c *cobra.Command, rel []string)
|
||||
walk = func(c *cobra.Command, rel []string) {
|
||||
for _, sub := range c.Commands() {
|
||||
if !sub.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
next := append(append([]string{}, rel...), sub.Name())
|
||||
if sub.Name() == name {
|
||||
out = append(out, strings.Join(next, " "))
|
||||
}
|
||||
walk(sub, next)
|
||||
}
|
||||
}
|
||||
walk(parent, nil)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func validateChartProperties(props map[string]any) error {
|
||||
// ─── Chart subcommands ──────────────────────────────────────────────────────
|
||||
|
||||
func newChartCmd() *cobra.Command {
|
||||
chartCmd := &cobra.Command{Use: "chart", Short: "浮动图表管理"}
|
||||
chartCmd := newDeepGroupCommand(&cobra.Command{Use: "chart", Short: "浮动图表管理"})
|
||||
|
||||
// ── chart list ──────────────────────────────────────────────────────
|
||||
chartListCmd := &cobra.Command{
|
||||
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
)
|
||||
|
||||
func newSheetCommentCmd() *cobra.Command {
|
||||
commentCmd := &cobra.Command{
|
||||
commentCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "comment",
|
||||
Short: "表格评论 / 单元格评论管理",
|
||||
Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
commentListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func newCondFormatCmd() *cobra.Command {
|
||||
condFormatCmd := &cobra.Command{Use: "cond-format", Short: "条件格式管理"}
|
||||
condFormatCmd := newDeepGroupCommand(&cobra.Command{Use: "cond-format", Short: "条件格式管理"})
|
||||
|
||||
condFormatListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user