Compare commits

..
Author SHA1 Message Date
修雨 735ef6b3f2 test(markdown): make disappearing-upload-file cases deterministic
The three temporary_file_disappears_before_stat subtests raced a polling
goroutine against a 16MB write, hoping the removal landed between write
and stat. On fast CI runners the window is routinely missed, failing
Coverage jobs on main and every open PR. Replace the race with an
injectable markdownStatUpload seam that removes the file before
delegating to os.Stat.
2026-07-24 11:12:52 +08:00
70 changed files with 559 additions and 15903 deletions
+4
View File
@@ -0,0 +1,4 @@
# Default code owners for all files
# These users will be automatically requested for review on PRs.
* @DingTalk-Real-AI/cli-maintainers
+9 -28
View File
@@ -3,41 +3,22 @@
- What changed?
- Why is this change needed?
## Risk tier
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
packaging, or interface behavior changed
- [ ] Standard: ordinary implementation change with a stable package graph
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
platform, auth/keychain, installer, packaging, release, transport, recovery,
or another fail-closed infrastructure change
## Verification
Record the smallest targeted evidence that proves the changed behavior. Do not
repeat the entire CI suite locally only to fill this checklist: CI expands the
selected tier from documentation checks, through affected-package tests, to
the complete high-risk suite.
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
other pull request, mark the targeted check `N/A` and complete the applicable
full-suite checks.
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
- [ ] Targeted test/check commands and results:
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
`N/A`)
- [ ] Full local suite run because the change is high-risk (optional for other
tiers; record command/result or `N/A`)
- [ ] `make build`
- [ ] `make lint`
- [ ] `make test`
- [ ] `make policy`
- [ ] `./scripts/policy/check-generated-drift.sh`
(when generator inputs or generated artifacts may change)
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
- [ ] `./scripts/release/verify-package-managers.sh`
(after `make package`, if packaging or installer surfaces changed)
## Notes
- Any risks, follow-up work, or intentional scope cuts
The repository automatically requests one eligible peer reviewer, including
after a new head push when another review is needed. Once the latest push has
peer approval and all nine required checks are current and green, auto-merge
completes the PR; authors do not need to coordinate a separate routine merge.
+108 -479
View File
@@ -25,12 +25,6 @@ jobs:
outputs:
changelog_only: ${{ steps.classify.outputs.changelog_only }}
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
docs_only: ${{ steps.classify.outputs.docs_only }}
full_suite: ${{ steps.classify.outputs.full_suite }}
release_sensitive: ${{ steps.classify.outputs.release_sensitive }}
interface_sensitive: ${{ steps.classify.outputs.interface_sensitive }}
mcp_sensitive: ${{ steps.classify.outputs.mcp_sensitive }}
edition_sensitive: ${{ steps.classify.outputs.edition_sensitive }}
platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }}
steps:
- name: Classify revision scope
@@ -40,176 +34,10 @@ jobs:
script: |
let changelogOnly = false;
let changelogChanged = false;
let docsOnly = false;
let fullSuite = context.eventName === 'push';
let releaseSensitive = context.eventName === 'push';
let interfaceSensitive = context.eventName === 'push';
let mcpSensitive = context.eventName === 'push';
let editionSensitive = context.eventName === 'push';
let platformSensitive = context.eventName === 'push';
let files = [];
let fastPathTrust = 'not evaluated';
const isDocsOnly = (filename) =>
typeof filename === 'string' &&
(
filename === 'CHANGELOG.md' ||
filename === 'README.md' ||
filename === 'README_zh.md' ||
filename === 'CONTRIBUTING.md' ||
filename === 'SECURITY.md' ||
filename === 'CODE_OF_CONDUCT.md' ||
filename === 'LICENSE' ||
filename === 'NOTICE' ||
filename === '.github/PULL_REQUEST_TEMPLATE.md' ||
filename.startsWith('.github/ISSUE_TEMPLATE/') ||
filename.startsWith('docs/')
);
const isReleaseSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('scripts/release/') ||
filename.startsWith('scripts/install') ||
filename.startsWith('Formula/') ||
filename.startsWith('build/') ||
filename === '.goreleaser.yaml' ||
filename === '.github/workflows/release.yml' ||
filename === '.github/workflows/withdraw-release.yml'
);
const isPlatformSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('internal/auth/') ||
filename.startsWith('internal/keychain/') ||
/_(darwin|windows|linux|unix)(?:_[a-z0-9]+)*\.go$/.test(filename) ||
filename === '.github/workflows/ci.yml' ||
filename ===
'scripts/policy/run-platform-coverage-gate.sh'
);
const isNativeGoChange = ({ filename, previous_filename, patch }) => {
const paths = [filename, previous_filename].filter(
(value) => typeof value === 'string' && value !== ''
);
if (paths.some(isPlatformSensitive)) {
return true;
}
if (!paths.some((value) => value.endsWith('.go'))) {
return false;
}
if (typeof patch !== 'string') {
return true;
}
return (
/\/\/go:build[^\n]*(darwin|windows|linux|unix)/.test(patch) ||
/runtime\.GO(OS|ARCH)/.test(patch)
);
};
const isInterfaceSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('cmd/') ||
filename.startsWith('internal/app/') ||
filename.startsWith('internal/cli/') ||
filename.startsWith('internal/cobracmd/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/i18n/') ||
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/ir/') ||
filename.startsWith('internal/shortcut/') ||
filename.startsWith('pkg/cmdutil/') ||
filename === 'skills_embed.go' ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
filename.startsWith('skills/')
);
const isMCPSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('internal/mcp') ||
filename.startsWith('internal/transport/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/app/') ||
filename.startsWith('test/mock_mcp/')
);
const isEditionSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('pkg/edition') ||
filename.startsWith('internal/edition') ||
filename === 'go.mod' ||
filename === 'go.sum'
);
const isHighRisk = (filename) =>
typeof filename === 'string' &&
(
isPlatformSensitive(filename) ||
isReleaseSensitive(filename) ||
filename.startsWith('.github/workflows/') ||
filename === '.github/actionlint.yaml' ||
filename.startsWith('scripts/') ||
filename.startsWith('verify/') ||
filename.startsWith('internal/generator/') ||
filename.startsWith('internal/cli/schema') ||
filename.startsWith('internal/interfacesnapshot/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
filename.startsWith('internal/recovery/') ||
filename.startsWith('internal/syncdata/') ||
filename.includes('/testdata/') ||
filename.startsWith('testdata/') ||
filename.startsWith('test/fixtures/') ||
filename === 'Makefile' ||
filename === 'go.mod' ||
filename === 'go.sum'
);
const classifyFiles = (complete) => {
const paths = files.flatMap(({ filename, previous_filename }) =>
[filename, previous_filename].filter(
(value) => typeof value === 'string' && value !== ''
)
);
const structuralChange = files.some(
({ status, previous_filename }) =>
status === 'removed' || Boolean(previous_filename)
);
const goPackageShapeChange = files.some(
({ filename, previous_filename, status }) =>
['added', 'removed'].includes(status) &&
[filename, previous_filename].some(
(value) =>
typeof value === 'string' && value.endsWith('.go')
)
);
docsOnly =
complete &&
files.length > 0 &&
paths.length > 0 &&
paths.every(isDocsOnly);
releaseSensitive =
!complete || paths.some(isReleaseSensitive);
platformSensitive =
!complete || files.some(isNativeGoChange);
interfaceSensitive =
!complete ||
paths.some(isInterfaceSensitive) ||
paths.some(isEditionSensitive);
mcpSensitive =
!complete || paths.some(isMCPSensitive);
editionSensitive =
!complete || paths.some(isEditionSensitive);
fullSuite =
!complete ||
(
!docsOnly &&
(
context.eventName === 'push' ||
structuralChange ||
goPackageShapeChange ||
files.some(isNativeGoChange) ||
paths.some(isHighRisk)
)
);
};
if (context.eventName === 'pull_request') {
const expectedHead = context.payload.pull_request.head.sha;
const expectedBase = context.payload.pull_request.base.sha;
@@ -257,14 +85,28 @@ jobs:
filename === 'CHANGELOG.md' ||
previous_filename === 'CHANGELOG.md'
);
classifyFiles(true);
const isPlatformSensitive = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('internal/auth/') ||
filename.startsWith('internal/keychain/') ||
/_(darwin|windows|linux|unix)\.go$/.test(filename) ||
filename.startsWith('scripts/release/') ||
filename.startsWith('scripts/install') ||
filename.startsWith('Formula/') ||
filename.startsWith('build/npm/') ||
filename === '.goreleaser.yaml' ||
filename === '.github/workflows/release.yml'
);
platformSensitive = files.some(
({ filename, previous_filename }) =>
isPlatformSensitive(filename) ||
isPlatformSensitive(previous_filename)
);
fastPathTrust = changelogOnly
? 'exact pull-request revision and synthetic merge policy'
: docsOnly
? 'documentation-only focused admission'
: fullSuite
? 'high-risk full admission'
: 'changed-package focused admission';
: 'full admission required';
} else if (context.eventName === 'push') {
const expectedBefore = context.payload.before;
const expectedAfter = context.payload.after;
@@ -294,7 +136,6 @@ jobs:
per_page: 100,
});
files = Array.isArray(comparison.files) ? comparison.files : [];
classifyFiles(files.length < 300);
const linearFromValidatedTip =
comparison.status === 'ahead' &&
comparison.merge_base_commit?.sha === expectedBefore &&
@@ -376,24 +217,12 @@ jobs:
core.setOutput('changelog_only', String(changelogOnly));
core.setOutput('changelog_changed', String(changelogChanged));
core.setOutput('docs_only', String(docsOnly));
core.setOutput('full_suite', String(fullSuite));
core.setOutput('release_sensitive', String(releaseSensitive));
core.setOutput('interface_sensitive', String(interfaceSensitive));
core.setOutput('mcp_sensitive', String(mcpSensitive));
core.setOutput('edition_sensitive', String(editionSensitive));
core.setOutput('platform_sensitive', String(platformSensitive));
await core.summary
.addHeading('Code Admission scope')
.addRaw(`- Event: \`${context.eventName}\`\n`)
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
.addRaw(`- Documentation-only: \`${docsOnly}\`\n`)
.addRaw(`- Full suite: \`${fullSuite}\`\n`)
.addRaw(`- Release-sensitive: \`${releaseSensitive}\`\n`)
.addRaw(`- Interface-sensitive: \`${interfaceSensitive}\`\n`)
.addRaw(`- MCP-sensitive: \`${mcpSensitive}\`\n`)
.addRaw(`- Edition-sensitive: \`${editionSensitive}\`\n`)
.addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`)
.addRaw(`- Changed files: \`${files.length}\`\n`)
.addRaw(`- Fast-path trust: ${fastPathTrust}\n`)
@@ -403,92 +232,36 @@ jobs:
if: steps.classify.outputs.changelog_only == 'true'
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
- name: Record documentation-only fast path
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true'
run: echo "Lint is satisfied by the non-executable documentation scope." >> "$GITHUB_STEP_SUMMARY"
- name: Check out repository
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
if: steps.classify.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
if: steps.classify.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify Test Package Plan
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
if: steps.classify.outputs.changelog_only != 'true'
run: make test-plan
- name: Format Check
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
if: steps.classify.outputs.changelog_only != 'true'
run: make format-check
- name: Go Vet
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
if: steps.classify.outputs.changelog_only != 'true'
run: go vet ./...
- name: Check GitHub Actions workflows
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
if: steps.classify.outputs.changelog_only != 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
test-focused:
name: Test (changed packages)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify authoritative synthetic merge
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
echo "focused test merge first parent does not match event base" >&2
exit 1
}
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
echo "focused test merge second parent does not match event head" >&2
exit 1
}
echo "TEST_BASE_REF=$PR_BASE_SHA" >> "$GITHUB_ENV"
echo "TEST_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test changed packages and reverse dependencies
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
package_output="$(
./scripts/ci/changed-test-packages.sh \
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
)"
if [ -z "$package_output" ]; then
echo "No buildable Go package is affected by this revision." \
>> "$GITHUB_STEP_SUMMARY"
exit 0
fi
mapfile -t packages <<< "$package_output"
go test -v -race -count=1 -timeout=8m "${packages[@]}"
test-race:
name: "Test (race: ${{ matrix.shard }})"
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
@@ -526,9 +299,9 @@ jobs:
go test -v -race -count=1 -timeout=10m "${packages[@]}"
test-release-scripts:
name: Test (workflow and release contracts)
name: Test (release scripts)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.release_sensitive == 'true') }}
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -558,7 +331,7 @@ jobs:
test-cross-platform:
name: Test (cross-platform compile)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -590,7 +363,6 @@ jobs:
name: Test
needs:
- lint
- test-focused
- test-race
- test-release-scripts
- test-cross-platform
@@ -604,10 +376,7 @@ jobs:
- name: Verify test shards
env:
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
DOCS_ONLY: ${{ needs.lint.outputs.docs_only }}
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
RELEASE_SENSITIVE: ${{ needs.lint.outputs.release_sensitive }}
FOCUSED_RESULT: ${{ needs.test-focused.result }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
RACE_RESULT: ${{ needs.test-race.result }}
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }}
CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }}
@@ -615,9 +384,8 @@ jobs:
WINDOWS_RESULT: ${{ needs.test-windows.result }}
run: |
failed=0
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
if [ "$CHANGELOG_ONLY" = true ]; then
for shard in \
"changed packages:$FOCUSED_RESULT" \
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT" \
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
@@ -635,34 +403,21 @@ jobs:
exit
fi
focused_expected=success
race_expected=skipped
if [ "$FULL_SUITE" = true ]; then
focused_expected=skipped
race_expected=success
fi
release_expected=skipped
if [ "$FULL_SUITE" = true ] || [ "$RELEASE_SENSITIVE" = true ]; then
release_expected=success
fi
for shard in \
"changed packages:$FOCUSED_RESULT:$focused_expected" \
"race shards:$RACE_RESULT:$race_expected" \
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
"race shards:$RACE_RESULT" \
"release scripts:$RELEASE_SCRIPTS_RESULT" \
"cross-platform compile:$CROSS_PLATFORM_RESULT"
do
name="${shard%%:*}"
remainder="${shard#*:}"
result="${remainder%%:*}"
expected="${remainder#*:}"
printf '%s: %s (expected %s)\n' "$name" "$result" "$expected"
if [ "$result" != "$expected" ]; then
result="${shard#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
native_expected=skipped
if [ "$FULL_SUITE" = true ]; then
if [ "$PLATFORM_SENSITIVE" = true ]; then
native_expected=success
fi
for native in \
@@ -681,7 +436,7 @@ jobs:
test-darwin:
name: Test (macOS auth/keychain)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
runs-on: macos-latest
timeout-minutes: 15
steps:
@@ -699,7 +454,7 @@ jobs:
test-windows:
name: Test (Windows)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
runs-on: windows-latest
timeout-minutes: 15
steps:
@@ -813,14 +568,13 @@ jobs:
coverage-current:
name: Coverage (current)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
@@ -829,69 +583,15 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COVERAGE_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: Build
run: make build
- name: Run current unit tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
run: |
set -euo pipefail
if [ "$FULL_SUITE" = true ]; then
go test -count=1 -p 1 \
-coverprofile=coverage.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
else
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
)"
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > coverage.txt
echo "No buildable Go package needs scoped coverage." \
>> "$GITHUB_STEP_SUMMARY"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 -p 1 \
-coverpkg="$coverpkg" \
-coverprofile=coverage.txt \
-covermode=atomic \
"${impacted_packages[@]}"
fi
fi
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
go tool cover -func=coverage.txt
fi
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go tool cover -func=coverage.txt
- name: Upload current coverage profile
uses: actions/upload-artifact@v4
@@ -903,7 +603,7 @@ jobs:
coverage-supporting:
name: Coverage (supporting)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -942,7 +642,7 @@ jobs:
coverage-baseline:
name: Coverage (baseline)
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -958,7 +658,6 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Resolve authoritative coverage base
@@ -979,24 +678,8 @@ jobs:
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run baseline unit tests with coverage
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
run: |
set -euo pipefail
changed_output=
impacted_output=
if [ "$FULL_SUITE" != true ]; then
changed_output="$(
./scripts/ci/changed-test-packages.sh \
changed "$COVERAGE_BASE_REF" HEAD
)"
impacted_output="$(
./scripts/ci/changed-test-packages.sh \
list "$COVERAGE_BASE_REF" HEAD
)"
fi
set -eu
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
rmdir "$base_worktree"
cleanup() {
@@ -1006,25 +689,11 @@ jobs:
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
(
cd "$base_worktree"
if [ "$FULL_SUITE" = true ]; then
go test -count=1 \
-p 1 \
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
elif [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
printf 'mode: atomic\n' > "$GITHUB_WORKSPACE/coverage-base.txt"
else
mapfile -t changed_packages <<< "$changed_output"
mapfile -t impacted_packages <<< "$impacted_output"
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
go test -count=1 \
-p 1 \
-coverpkg="$coverpkg" \
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
-covermode=atomic \
"${impacted_packages[@]}"
fi
go test -count=1 \
-p 1 \
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
- name: Upload baseline coverage profile
@@ -1050,8 +719,6 @@ jobs:
- name: Verify coverage profile jobs
env:
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
DOCS_ONLY: ${{ needs.lint.outputs.docs_only }}
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
CURRENT_RESULT: ${{ needs.coverage-current.result }}
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
@@ -1060,32 +727,22 @@ jobs:
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
run: |
failed=0
current_expected=success
supporting_expected=skipped
baseline_expected=success
expected=success
native_expected=skipped
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
current_expected=skipped
baseline_expected=skipped
elif [ "$FULL_SUITE" = true ]; then
supporting_expected=success
fi
if [ "$CHANGELOG_ONLY" != true ] &&
[ "$DOCS_ONLY" != true ] &&
[ "$PLATFORM_SENSITIVE" = true ]; then
if [ "$CHANGELOG_ONLY" = true ]; then
expected=skipped
elif [ "$PLATFORM_SENSITIVE" = true ]; then
native_expected=success
fi
for profile in \
"current:$CURRENT_RESULT:$current_expected" \
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
"baseline:$BASELINE_RESULT:$baseline_expected"
"current:$CURRENT_RESULT" \
"supporting:$SUPPORTING_RESULT" \
"baseline:$BASELINE_RESULT"
do
name="${profile%%:*}"
remainder="${profile#*:}"
result="${remainder%%:*}"
expected="${remainder#*:}"
printf '%s: %s (expected %s)\n' "$name" "$result" "$expected"
result="${profile#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "$expected" ]; then
failed=1
fi
@@ -1097,8 +754,10 @@ jobs:
do
name="${native%%:*}"
result="${native#*:}"
printf '%s: %s (expected %s)\n' \
"$name" "$result" "$native_expected"
printf '%s: %s\n' "$name" "$result"
if [ "$CHANGELOG_ONLY" = true ]; then
native_expected=skipped
fi
if [ "$result" != "$native_expected" ]; then
failed=1
fi
@@ -1106,20 +765,20 @@ jobs:
test "$failed" -eq 0
- name: Check out repository
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -1137,52 +796,40 @@ jobs:
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Download current coverage profile
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-current-profile
path: .
- name: Download supporting coverage profiles
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-supporting-profiles
path: .
- name: Download baseline coverage profile
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/download-artifact@v4
with:
name: coverage-baseline-profile
path: .
- name: Enforce coverage gate
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
env:
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0"
run: |
additional_profile=
if [ -f coverage-shortcut.txt ]; then
additional_profile=coverage-shortcut.txt
fi
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
- name: Generate coverage report
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
run: |
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
go tool cover -html=coverage.txt -o coverage.html
else
echo "No buildable Go package needed a coverage HTML report." \
>> "$GITHUB_STEP_SUMMARY"
fi
if: needs.lint.outputs.changelog_only != 'true'
run: go tool cover -html=coverage.txt -o coverage.html
- name: Upload coverage artifact
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/upload-artifact@v4
with:
name: coverage-report
@@ -1192,7 +839,6 @@ jobs:
coverage-policy.txt
coverage-shortcut.txt
coverage.html
if-no-files-found: error
policy:
name: Policy
@@ -1206,7 +852,7 @@ jobs:
fetch-depth: 0
- name: Set up Go
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
@@ -1284,29 +930,12 @@ jobs:
echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \
>> "$GITHUB_STEP_SUMMARY"
- name: Validate scoped policy
if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }}
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$PR_BASE_SHA"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
git diff --check "$base_ref" HEAD
./scripts/policy/check-open-source-assets.sh
echo "Policy used the scoped content/source admission path." \
>> "$GITHUB_STEP_SUMMARY"
- name: Build
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make build
- name: Policy
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make policy
interface-integrity:
@@ -1316,24 +945,24 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out repository
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make build
- name: Resolve authoritative compatibility merge-base
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -1358,7 +987,7 @@ jobs:
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
@@ -1368,16 +997,16 @@ jobs:
fi
- name: Check complete Schema compatibility
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
- name: Check skill command references
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make skill-command-integrity
- name: Record scoped fast path
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }}
run: echo "Interface Integrity is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
cli-smoke:
name: CLI Smoke
@@ -1386,26 +1015,26 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out repository
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make build
- name: Check public top-level commands
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make cli-smoke
- name: Record scoped fast path
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }}
run: echo "CLI Smoke is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
mock-mcp-smoke:
name: Mock MCP
@@ -1414,22 +1043,22 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out repository
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check HTTP and stdio MCP transport
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: make mock-mcp-smoke
- name: Record scoped fast path
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.mcp_sensitive != 'true') }}
run: echo "Mock MCP is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
edition-tests:
name: Edition
@@ -1438,19 +1067,19 @@ jobs:
timeout-minutes: 10
steps:
- name: Check out repository
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/checkout@v4
- name: Set up Go
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run edition contract tests
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }}
if: needs.lint.outputs.changelog_only != 'true'
run: go test -v -count=1 ./pkg/editiontest/...
- name: Record scoped fast path
if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.edition_sensitive != 'true') }}
run: echo "Edition is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"
- name: Record CHANGELOG-only fast path
if: needs.lint.outputs.changelog_only == 'true'
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
+157 -392
View File
@@ -78,7 +78,7 @@ on:
required: false
type: string
recover_release_nonce:
description: "Unique identifier for this machine-verified recovery request"
description: "Unique identifier for this protected recovery request"
required: false
type: string
recover_release_confirmation:
@@ -219,6 +219,44 @@ jobs:
echo "mode=recover_release" >> "$GITHUB_OUTPUT"
fi
authorize-recovery:
name: Approve existing tag recovery
needs: dispatch-contract
if: ${{ needs.dispatch-contract.outputs.mode == 'recover_release' }}
environment: release-recovery
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
actions: read
steps:
- name: Verify break-glass environment protection
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: "release-recovery" },
);
const reviewerRule = response.data.protection_rules.find(
(rule) => rule.type === "required_reviewers",
);
if (
!reviewerRule ||
reviewerRule.prevent_self_review !== true ||
!Array.isArray(reviewerRule.reviewers) ||
reviewerRule.reviewers.length === 0
) {
core.setFailed("release-recovery must require a reviewer and prevent self-review");
return;
}
if (response.data.deployment_branch_policy?.protected_branches !== true) {
core.setFailed("release-recovery must allow only protected branches");
}
if (response.data.can_admins_bypass !== false) {
core.setFailed("release-recovery must not allow administrator bypass");
}
governance-preflight:
name: Release governance preflight
needs: dispatch-contract
@@ -372,9 +410,24 @@ jobs:
core.setFailed("Immutable releases must be enabled before publishing");
}
- name: Verify Homebrew PR automation permission
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
test -n "$HOMEBREW_PR_TOKEN" || {
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
}
test "$HOMEBREW_PR_TOKEN" != "$RELEASE_GOVERNANCE_TOKEN" || {
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
exit 1
}
./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary
release-contract:
needs: [dispatch-contract, governance-preflight, release-plan, seal-release]
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release]
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -515,8 +568,6 @@ jobs:
if (isCloudSeal) {
const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable";
const actorId = String(context.payload.sender?.id || "");
const sealedRunAttempt = Number(tagFields.get("Release-Run-Attempt"));
const currentRunAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
if (
tagFields.get("Channel") !== expectedChannel ||
tagFields.get("Sealed-Commit") !== commit ||
@@ -531,10 +582,7 @@ jobs:
}
if (isCreate && (
tagFields.get("Release-Run") !== String(context.runId) ||
!Number.isSafeInteger(sealedRunAttempt) ||
!Number.isSafeInteger(currentRunAttempt) ||
sealedRunAttempt < 1 ||
sealedRunAttempt > currentRunAttempt ||
tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT ||
tagFields.get("Requested-By") !== context.actor ||
tagFields.get("Requested-By-ID") !== actorId
)) {
@@ -594,7 +642,6 @@ jobs:
if (failedByCloud && (
tagFields.get("Release-Run") !== failedRunId ||
tagFields.get("Release-Run-Attempt") !== failedRunAttempt ||
tagFields.get("Requested-By") !== String(run.actor?.login || "") ||
tagFields.get("Requested-By-ID") !== String(run.actor?.id || "")
)) {
core.setFailed(
@@ -771,6 +818,23 @@ jobs:
core.setFailed("Immutable releases must be enabled before publishing");
}
- name: Verify Homebrew PR automation permission
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
RELEASE_GOVERNANCE_TOKEN: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}
run: |
if [ -z "$HOMEBREW_PR_TOKEN" ]; then
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
fi
if [ "$HOMEBREW_PR_TOKEN" = "$RELEASE_GOVERNANCE_TOKEN" ]; then
echo "HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities" >&2
exit 1
fi
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-homebrew-pr-token.sh" \
"$GITHUB_REPOSITORY" --canary
- name: Require successful beta delivery before stable promotion
if: ${{ steps.contract.outputs.channel == 'stable' && (github.event_name == 'push' || needs.dispatch-contract.outputs.mode == 'recover_release') }}
uses: actions/github-script@v7
@@ -1082,7 +1146,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
checks: write
contents: write
env:
RELEASE_VERSION: ${{ needs.release-contract.outputs.release_version }}
@@ -1436,189 +1499,29 @@ jobs:
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Publish stable Homebrew formula
id: homebrew-stable
- name: Open stable Homebrew formula PR
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'stable' }}
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli.rb
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_BRANCH: main
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
DWS_GIT_NAME: github-actions[bot]
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ needs.release-contract.outputs.release_version }}"
- name: Publish beta Homebrew formula
id: homebrew-beta
- name: Open beta Homebrew formula PR
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && needs.release-contract.outputs.channel == 'prerelease' }}
run: DWS_PUBLISH_OUTPUT="$GITHUB_OUTPUT" "$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/publish-homebrew-formula.sh"
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_FORMULA_SOURCE: ${{ github.workspace }}/dist/homebrew/dingtalk-workspace-cli-beta.rb
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_BRANCH: main
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }} [skip ci]"
DWS_GIT_NAME: github-actions[bot]
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
- name: Seal Formula-only Code Admission contexts
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
uses: actions/github-script@v7
env:
FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}
FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
with:
script: |
const fs = require("fs");
const { owner, repo } = context.repo;
const commit = process.env.FORMULA_COMMIT;
const formulaChanged = process.env.FORMULA_CHANGED === "true";
const channel = process.env.RELEASE_CHANNEL;
const version = process.env.RELEASE_VERSION;
const formulaPath = channel === "stable"
? "Formula/dingtalk-workspace-cli.rb"
: "Formula/dingtalk-workspace-cli-beta.rb";
const sourcePath = channel === "stable"
? "dist/homebrew/dingtalk-workspace-cli.rb"
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
const expectedMessage = channel === "stable"
? `chore: update formula for ${version} [skip ci]`
: `chore: update beta formula for ${version} [skip ci]`;
if (!/^[0-9a-f]{40}$/.test(commit)) {
core.setFailed("Homebrew publisher did not return one full commit SHA");
return;
}
const commitResponse = await github.rest.repos.getCommit({
owner,
repo,
ref: commit,
per_page: 100,
});
const files = commitResponse.data.files || [];
const requiredContexts = [
"Lint",
"Test",
"Coverage",
"Policy",
"Edition",
"Interface Integrity",
"AI Behavior",
"CLI Smoke",
"Mock MCP",
];
const exactFormulaCommit =
commitResponse.data.sha === commit &&
commitResponse.data.parents.length === 1 &&
commitResponse.data.commit.message === expectedMessage &&
commitResponse.data.author?.login === "github-actions[bot]" &&
commitResponse.data.committer?.login === "github-actions[bot]" &&
files.length === 1 &&
files[0].filename === formulaPath &&
["added", "modified"].includes(files[0].status);
if (!exactFormulaCommit) {
if (formulaChanged) {
core.setFailed(
`${commit} is not the exact github-actions Formula-only commit for ${version}`,
);
} else {
core.info("Formula was already current; no Formula-only check sealing is needed.");
}
return;
}
const parent = commitResponse.data.parents[0].sha;
const parentRuns = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: parent,
filter: "latest",
per_page: 100,
});
const latestParentRunByName = new Map();
for (const run of parentRuns) {
if (
run.head_sha !== parent ||
run.app?.slug !== "github-actions" ||
!requiredContexts.includes(run.name)
) {
continue;
}
const current = latestParentRunByName.get(run.name);
if (!current || run.id > current.id) {
latestParentRunByName.set(run.name, run);
}
}
const invalidParentContexts = requiredContexts.filter((name) => {
const run = latestParentRunByName.get(name);
return !run || run.conclusion !== "success";
});
if (invalidParentContexts.length > 0) {
core.setFailed(
`Formula commit parent ${parent} lacks successful Code Admission contexts: ` +
invalidParentContexts.join(", "),
);
return;
}
const remoteFormula = await github.rest.repos.getContent({
owner,
repo,
path: formulaPath,
ref: commit,
});
if (
Array.isArray(remoteFormula.data) ||
remoteFormula.data.type !== "file" ||
remoteFormula.data.encoding !== "base64"
) {
core.setFailed(`${formulaPath} is not a regular base64 GitHub blob at ${commit}`);
return;
}
const expectedFormula = fs.readFileSync(sourcePath);
const actualFormula = Buffer.from(
remoteFormula.data.content.replace(/\s/g, ""),
"base64",
);
if (!actualFormula.equals(expectedFormula)) {
core.setFailed(`${formulaPath} at ${commit} differs from the verified release Formula`);
return;
}
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: context.payload.repository.default_branch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commit}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${commit} is no longer contained in the default branch`);
return;
}
for (const name of requiredContexts) {
await github.rest.checks.create({
owner,
repo,
name,
head_sha: commit,
status: "completed",
conclusion: "success",
output: {
title: "Verified Formula-only release commit",
summary:
`${formulaPath} was generated from immutable ${version} assets, ` +
"validated byte-for-byte, and was the commit's only changed file.",
},
});
}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ needs.release-contract.outputs.release_version }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ needs.release-contract.outputs.release_version }}"
- name: Reverify exact immutable npm package
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
@@ -2146,6 +2049,7 @@ jobs:
if: ${{ !cancelled() }}
needs:
- dispatch-contract
- authorize-recovery
- governance-preflight
- release-contract
- release-validation
@@ -2168,6 +2072,7 @@ jobs:
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
GITEE_FALLBACK_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK }}
DISPATCH_RESULT: ${{ needs.dispatch-contract.result }}
AUTHORIZE_RECOVERY_RESULT: ${{ needs.authorize-recovery.result }}
GOVERNANCE_PREFLIGHT_RESULT: ${{ needs.governance-preflight.result }}
RELEASE_CONTRACT_RESULT: ${{ needs.release-contract.result }}
RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}
@@ -2211,6 +2116,7 @@ jobs:
case "$EVENT_NAME:$DISPATCH_MODE" in
push:)
require_result dispatch-contract "$DISPATCH_RESULT" skipped
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
@@ -2222,6 +2128,7 @@ jobs:
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result release-plan "$RELEASE_PLAN_RESULT" success
require_result seal-release "$SEAL_RELEASE_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_publication
@@ -2230,6 +2137,7 @@ jobs:
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result release-plan "$RELEASE_PLAN_RESULT" success
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
@@ -2243,6 +2151,7 @@ jobs:
;;
workflow_dispatch:recover_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
@@ -2252,6 +2161,7 @@ jobs:
workflow_dispatch:governance_preflight)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
@@ -2266,6 +2176,7 @@ jobs:
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-npm "$REPAIR_NPM_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
@@ -2280,6 +2191,7 @@ jobs:
workflow_dispatch:repair_gitee)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
@@ -2294,6 +2206,7 @@ jobs:
workflow_dispatch:repair_oss)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-channel "$REPAIR_CHANNEL_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release-validation "$RELEASE_VALIDATION_RESULT" skipped
@@ -2903,94 +2816,10 @@ jobs:
repo,
branch: defaultBranch,
});
const currentBranchCommit = branch.data.commit.sha;
const branchMoved = branch.data.commit.sha !== commit;
const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT);
const requestedById = String(context.payload.sender?.id || "");
if (!Number.isSafeInteger(currentAttempt) || currentAttempt < 1 || !/^[1-9]\d*$/.test(requestedById)) {
core.setFailed("cloud release request contains an invalid run attempt or requester identity");
if (branch.data.commit.sha !== commit) {
core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`);
return;
}
const messageForAttempt = (attempt) => [
`Release ${version}`,
"",
`Channel: ${channel}`,
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
`OSS-Mirror: ${ossMirror}`,
`Release-Run: ${context.runId}`,
`Release-Run-Attempt: ${attempt}`,
`Requested-By: ${context.actor}`,
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
`Sealed-Commit: ${commit}`,
`Workflow-Commit: ${context.sha}`,
`Allocation-Fingerprint: ${expectedFingerprint}`,
].join("\n");
const retryablePostWriteError = (error) =>
error.status === 404 ||
error.status === 429 ||
(
Number.isInteger(error.status) &&
error.status >= 500 &&
error.status < 600
);
const postWriteRetryDelaysMs = [0, 1000, 2000, 4000, 8000, 16000];
const sleep = (delayMs) =>
new Promise((resolve) => setTimeout(resolve, delayMs));
const readAfterWrite = async (description, operation) => {
let lastError;
for (const delayMs of postWriteRetryDelaysMs) {
if (delayMs > 0) {
core.info(`${description} is not visible yet; retrying in ${delayMs}ms`);
await sleep(delayMs);
}
try {
return await operation();
} catch (error) {
lastError = error;
if (!retryablePostWriteError(error)) {
throw error;
}
}
}
throw lastError;
};
const verifyExactTag = async (refResponse, expectedTagObject = "") => {
if (refResponse.data.object.type !== "tag") {
throw new Error(`${version} does not point to an annotated tag object`);
}
const tagObject = refResponse.data.object.sha;
if (expectedTagObject && tagObject !== expectedTagObject) {
throw new Error(
`${version} points to ${tagObject}, expected new tag object ${expectedTagObject}`,
);
}
const tagResponse = await readAfterWrite(
`${version} annotated tag object`,
() => github.rest.git.getTag({ owner, repo, tag_sha: tagObject }),
);
const attemptMatches = [
...tagResponse.data.message.matchAll(/^Release-Run-Attempt: ([1-9]\d*)$/gm),
];
if (attemptMatches.length !== 1) {
throw new Error(`${version} has invalid Release-Run-Attempt metadata`);
}
const sealedAttempt = Number(attemptMatches[0][1]);
if (
!Number.isSafeInteger(sealedAttempt) ||
sealedAttempt < 1 ||
sealedAttempt > currentAttempt ||
tagResponse.data.tag !== version ||
tagResponse.data.object.type !== "commit" ||
tagResponse.data.object.sha !== commit ||
tagResponse.data.message !== messageForAttempt(sealedAttempt)
) {
throw new Error(
`${version} existing annotated tag is not the exact seal from this workflow run`,
);
}
return tagObject;
};
const refs = [];
for (const prefix of ["tags/v", "tags/withdrawn/v"]) {
@@ -3001,12 +2830,6 @@ jobs:
per_page: 100,
});
for (const ref of matching) {
// A failed-jobs rerun keeps the successful release-plan output.
// Ignore only this run's candidate ref while recomputing that
// pre-write fingerprint; verify its complete authority below.
if (ref.ref === `refs/tags/${version}`) {
continue;
}
refs.push(`${ref.ref}=${ref.object.sha}`);
}
}
@@ -3020,13 +2843,10 @@ jobs:
return;
}
let existingRef = null;
try {
existingRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
core.setFailed(`release tag already exists: ${version}`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
@@ -3042,121 +2862,66 @@ jobs:
if (error.status !== 404) throw error;
}
if (branchMoved) {
if (!existingRef) {
core.setFailed(
`${defaultBranch} moved to ${currentBranchCommit} before the tag was created; run the release again`,
);
return;
}
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commit}...${currentBranchCommit}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(
`${version} sealed commit is no longer contained in ${defaultBranch}`,
);
return;
}
const message = [
`Release ${version}`,
"",
`Channel: ${channel}`,
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
`OSS-Mirror: ${ossMirror}`,
`Release-Run: ${context.runId}`,
`Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`,
`Requested-By: ${context.actor}`,
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
`Sealed-Commit: ${commit}`,
`Workflow-Commit: ${context.sha}`,
`Allocation-Fingerprint: ${expectedFingerprint}`,
].join("\n");
const tag = await github.rest.git.createTag({
owner,
repo,
tag: version,
message,
object: commit,
type: "commit",
});
await github.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${version}`,
sha: tag.data.sha,
});
const sealedRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (
sealedRef.data.object.type !== "tag" ||
sealedRef.data.object.sha !== tag.data.sha
) {
core.setFailed(`${version} did not seal to the new annotated tag object`);
return;
}
let sealedTagObject = "";
let reusedExistingSeal = false;
if (existingRef) {
sealedTagObject = await verifyExactTag(existingRef);
reusedExistingSeal = true;
core.info(
`Reusing exact ${version} seal from run ${context.runId}; continuing failed jobs without recovery approval`,
);
} else {
const message = messageForAttempt(currentAttempt);
const tag = await github.rest.git.createTag({
owner,
repo,
tag: version,
message,
object: commit,
type: "commit",
});
if (
tag.data.tag !== version ||
tag.data.object.type !== "commit" ||
tag.data.object.sha !== commit ||
tag.data.message !== message
) {
core.setFailed(`${version} annotated tag creation response differs from the seal`);
return;
}
let createRefResponse = null;
let createRefError = null;
for (const delayMs of postWriteRetryDelaysMs) {
if (delayMs > 0) {
core.info(
`${version} ref creation is not confirmed; retrying in ${delayMs}ms`,
);
await sleep(delayMs);
}
try {
createRefResponse = await github.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${version}`,
sha: tag.data.sha,
});
break;
} catch (error) {
createRefError = error;
if (
error.status !== 422 &&
error.status !== 429 &&
!(Number.isInteger(error.status) && error.status >= 500 && error.status < 600)
) {
throw error;
}
try {
const observedRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
await verifyExactTag(observedRef, tag.data.sha);
createRefResponse = observedRef;
break;
} catch (observeError) {
if (!retryablePostWriteError(observeError)) {
throw observeError;
}
}
}
}
if (!createRefResponse) {
throw createRefError;
}
if (
createRefResponse.data.object.type !== "tag" ||
createRefResponse.data.object.sha !== tag.data.sha
) {
core.setFailed(`${version} ref creation response differs from the new tag object`);
return;
}
const sealedRef = await readAfterWrite(
`${version} tag ref`,
() => github.rest.git.getRef({ owner, repo, ref: `tags/${version}` }),
);
sealedTagObject = await verifyExactTag(sealedRef, tag.data.sha);
const sealedTag = await github.rest.git.getTag({
owner,
repo,
tag_sha: tag.data.sha,
});
if (
sealedTag.data.tag !== version ||
sealedTag.data.object.type !== "commit" ||
sealedTag.data.object.sha !== commit ||
sealedTag.data.message !== message
) {
core.setFailed(`${version} annotated tag authority differs after creation`);
return;
}
core.setOutput("release_version", version);
core.setOutput("release_commit", commit);
core.setOutput("release_tag_object", sealedTagObject);
core.setOutput("release_tag_object", tag.data.sha);
await core.summary
.addHeading("Cloud release sealed")
.addRaw(
`\`${version}\` → \`${commit}\` (run ${context.runId}` +
`${reusedExistingSeal ? ", exact seal reused" : ""})`,
)
.addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`)
.write();
-258
View File
@@ -1,258 +0,0 @@
name: Reviewer routing
on:
pull_request_target:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
# pull_request_target deliberately runs only this workflow from the protected
# base branch. Never check out or execute pull-request code here.
permissions:
contents: write
pull-requests: write
concurrency:
group: reviewer-router-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
route:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
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 reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (
currentPull.head.sha !== eventHeadSha ||
currentPull.state !== 'open' ||
currentPull.draft ||
currentPull.base.ref !== 'main'
) {
core.info(
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
);
return null;
}
return currentPull;
}
const pullRequest = await getReadyEventPull('initial read');
if (!pullRequest) {
return;
}
const author = pullRequest.user.login.toLowerCase();
const headSha = pullRequest.head.sha;
const latestPusher =
context.payload.action === 'synchronize'
? context.payload.sender?.login?.toLowerCase()
: author;
async function routeReview() {
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
reviewer.toLowerCase() !== latestPusher,
);
if (eligible.length === 0) {
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
return;
}
const alreadyRequested =
(pullRequest.requested_reviewers || []).length > 0 ||
(pullRequest.requested_teams || []).length > 0;
if (alreadyRequested) {
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
return;
}
let reviews;
try {
reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
);
return;
}
const latestDecisionByLogin = new Map();
for (const review of reviews) {
const login = review.user?.login?.toLowerCase();
if (
!login ||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
review.state,
)
) {
continue;
}
const previous = latestDecisionByLogin.get(login);
if (!previous || review.id > previous.id) {
latestDecisionByLogin.set(login, review);
}
}
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer =>
reviewer.toLowerCase() ===
review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
);
if (currentHeadDecision) {
core.info(
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
);
return;
}
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
try {
const openPullRequests = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
for (const openPullRequest of openPullRequests) {
for (const reviewer of openPullRequest.requested_reviewers || []) {
const candidate = eligible.find(
login => login.toLowerCase() === reviewer.login.toLowerCase(),
);
if (candidate) {
loads.set(candidate, loads.get(candidate) + 1);
}
}
}
} catch (error) {
core.warning(
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
);
}
const offset = pullNumber % eligible.length;
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
const staleChangeRequester = [...latestDecisionByLogin.values()]
.filter(review => review.state === 'CHANGES_REQUESTED')
.sort((left, right) => right.id - left.id)
.map(review =>
eligible.find(
reviewer =>
reviewer.toLowerCase() === review.user.login.toLowerCase(),
),
)
.find(Boolean);
const ranked = [...eligible].sort(
(left, right) =>
Number(right === staleChangeRequester) -
Number(left === staleChangeRequester) ||
loads.get(left) - loads.get(right) ||
tieOrder.get(left) - tieOrder.get(right),
);
for (const reviewer of ranked) {
try {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return;
}
if (
(currentPull.requested_reviewers || []).length > 0 ||
(currentPull.requested_teams || []).length > 0
) {
core.info(
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
);
return;
}
await github.rest.pulls.requestReviewers({
owner,
repo,
pull_number: pullNumber,
reviewers: [reviewer],
});
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return;
} catch (error) {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
}
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
}
async function enableAutoMerge() {
try {
const currentPull = await getReadyEventPull('auto-merge enable');
if (!currentPull) {
return;
}
if (currentPull.auto_merge) {
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
return;
}
await github.graphql(
`mutation EnableAutoMerge($pullRequestId: ID!) {
enablePullRequestAutoMerge(
input: {
pullRequestId: $pullRequestId
mergeMethod: MERGE
}
) {
pullRequest {
autoMergeRequest {
enabledAt
}
}
}
}`,
{pullRequestId: currentPull.node_id},
);
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
} catch (error) {
core.warning(
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
);
}
}
try {
await routeReview();
} catch (error) {
core.warning(
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
);
}
await enableAutoMerge();
+1 -18
View File
@@ -4,23 +4,7 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [1.0.55-beta.3] - 2026-07-24
This beta validates the HR Brain command surface, smoother guarded release
automation, and deterministic Markdown test coverage on top of the
`v1.0.55-beta.2` baseline.
### Added
- **HR Brain (`dws hrbrain`) command surface** — adds 11 commands across three groups: `talent-pool list/detail/employees` for talent pool browsing, `profile metadata/query/labels/career/performance` for employee profile data, and `search employees/employees-structured/fields` for basic and advanced (rule-based) people search. Ships with bundled mono/multi Skill guidance (`dingtalk-hrbrain`, `cli_version: ">=1.0.54"`); `search employees-structured` validates `--origin-json` as a JSON object and `--fields` as a JSON array before dispatch.
### Changed
- **Smoother guarded releases** — publishes verified stable and beta Homebrew Formula updates directly from the release workflow, retries transient tag-ref visibility failures, lets an exact same-run retry reuse its sealed tag, and allows machine-verified rebuild recovery without a separate approval wait.
### Fixed
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
## [Unreleased]
## [1.0.55-beta.2] - 2026-07-23
@@ -63,7 +47,6 @@ records, encrypted messages, and media-download ID aliases.
### Fixed
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
## [1.0.54] - 2026-07-21
+8 -39
View File
@@ -27,9 +27,7 @@ notes that are intentionally kept out of the repository root.
## Local Checks
Run the verification commands that match the surface you changed before you
hand work back. The goal is useful, change-specific evidence, not a second
local execution of every CI job.
Run the verification commands that match the surface you changed before you hand work back.
Common repository checks already used here include:
@@ -46,48 +44,19 @@ make lint
git diff --check
```
Select the PR risk tier before choosing checks:
| Tier | Typical scope | Developer evidence | CI expansion |
|---|---|---|---|
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
Classification fails closed: an incomplete diff, package add/remove/rename, or
uncertain dependency graph selects the high-risk suite. Native changed-code
coverage is additionally selected for platform-sensitive code.
## Pull Request Checklist
1. Keep implementation and tests in sync.
2. Select the documentation-only, standard, or high-risk tier and run the
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
a complete local pass is warranted; it is not required for every ordinary
PR.
3. Include both the commands/results and user-visible or contract-level
behavior evidence in the PR description.
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
paths/flags change. CI also runs
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
against both the target branch and latest stable release.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
installer surfaces change (run `make package` first).
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
2. Run `./scripts/dev/ci-local.sh`.
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
7. Include verification evidence in your PR description.
## Submission Flow
1. Make the smallest atomic change that satisfies the task.
2. Keep doc edits factual and limited to implemented behavior.
3. Run the relevant verification commands.
4. Report the validation results and risk tier with the handoff.
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
peer reviewer, balancing the current open-review load and excluding the
author. A new head push re-enters the same routing flow when the latest
revision still needs review.
6. After the latest push has one peer approval and the exact nine required
contexts are current and green, auto-merge completes the PR. If `main`
advances first, strict status checks revalidate the branch; no separate
routine merge request is needed.
4. Report the validation results with the handoff.
+7 -20
View File
@@ -52,13 +52,7 @@ run.
```mermaid
flowchart TB
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
DOCS --> CA["CI"]
STANDARD --> CA
HIGH --> CA
PR["Pull request"] --> CA["CI"]
subgraph CA_CHECKS["Nine required contexts"]
L["Lint"]
T["Test"]
@@ -78,16 +72,9 @@ flowchart TB
PLATFORM --> RELEASE
```
All nine named contexts are produced for every tier. Domain-specific helpers
run when their owned surface is affected; otherwise the corresponding context
records an explicit unaffected success. Standard code changes still receive
representative Darwin/Windows compilation. High-risk PRs and protected `main`
run the complete race and native test suites, while platform-sensitive diffs
also receive native changed-code coverage.
Review orchestration is also base-owned: it requests one eligible peer without
executing PR code, re-routes an updated head when needed, and auto-merge
completes only after the latest push has peer approval plus the current
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
classification, context, reviewer, and ruleset contract.
Complete Multi-profile E2E and the ordinary full native-platform matrix are
downstream of PR admission. PRs still run primary-environment assurance and
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
release changes additionally select native platform tests before merge. See
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
contract.
+32 -26
View File
@@ -62,27 +62,32 @@ make lint
git diff --check
```
## Homebrew Formula Delivery
## Homebrew Formula PR Automation
Official releases use the Release workflow's built-in `GITHUB_TOKEN` 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
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
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.
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
maintainer or release-bot account, limited to this repository with
`Contents: write` and `Pull requests: write`. If organization policy prevents
that account from targeting the repository, use a dedicated classic token with
only the `public_repo` scope. Do not reuse a broad developer token.
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
keep its owner as the designated ruleset bypass actor, and do not reuse
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
path restriction; GitHub rulesets do not infer that restriction from the token.
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
and rotate it before its configured expiration. Replace it immediately if it is
exposed, its owner loses repository access, or the release-bot ownership
changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
The default-branch governance preflight and every tag contract authenticate the
token before publication, reject over-scoped classic tokens, confirm its
identity, and run a controlled write canary. The canary pushes a unique
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
draft PR, closes it, and deletes the branch with the same token. This proves both
Contents and Pull requests write access before publication without merging
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
separate workflow approval.
## Release Governance and Recovery
@@ -93,14 +98,15 @@ administration setting and cannot be read by the workflow's built-in
contract use this same credential so a missing or expired identity is detected
before an irreversible tag is created.
Create a protected `release-recovery` environment limited to protected
branches, with a required reviewer, self-review disabled, and administrator
bypass disabled. The workflow reads the environment through the GitHub API and
fails closed unless the required-reviewer, prevent-self-review, and protected-
branch rules are present.
Recovery is restricted to an existing annotated tag whose exact tag object,
commit, sealed metadata, original failed run/attempt, requester identity and
Release state all match; it then reuses the normal release jobs without a
second-person environment approval. A same-run “Re-run failed jobs” is even
lighter: the seal job may adopt an existing tag only when its complete
authority matches that run and its original attempt is not newer than the
current attempt. Do not put publication secrets in temporary branches or
create ad-hoc recovery workflows.
commit, and failed tag-push run all match; it then reuses the normal release
jobs. Do not put publication secrets in temporary branches or create ad-hoc
recovery workflows.
Cloud-sealed releases mirror to OSS only when the repository variable
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
+18 -59
View File
@@ -4,9 +4,9 @@ The pull-request admission layer has exactly nine required external contexts:
| Required context | Contract |
|---|---|
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
| `Coverage` | Scope-matched overall non-regression and 100% changed-code coverage |
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
| `Coverage` | Overall non-regression and 100% changed-code coverage |
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
| `Edition` | Edition contract tests |
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
@@ -84,28 +84,13 @@ second file but still rejects invalid dates or versions, missing bullets,
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
Adding a second file therefore cannot bypass CHANGELOG validation.
## Risk tiers and downstream boundaries
## Platform and downstream boundaries
`Lint` resolves the complete base/head diff before any helper is skipped.
Unknown or truncated input fails closed into the high-risk tier.
| Tier | Selection | Admission work |
|---|---|---|
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
for example) execute their substantive suites when the diff can affect that
contract or when the high-risk tier is selected. Otherwise their stable named
contexts still report a successful, explicit unaffected result. Release-script
tests follow the same impact rule. This preserves the ruleset contract without
charging every developer for unrelated work.
Platform-sensitive changes additionally run native changed-code coverage.
Protected `main` always runs native tests; generic portable changes are held to
the Linux changed-code gate rather than being forced to manufacture
platform-only coverage.
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
checks. Full native macOS/Windows tests and platform coverage run on a PR only
when its diff touches auth, keychain, OS-specific Go files, installers,
packaging, Formulae, or release automation. Protected `main` pushes run the
complete native matrix.
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
@@ -131,28 +116,9 @@ flowchart TB
MAIN --> RELEASE["Release delivery"]
```
## Review ownership and auto-merge
A base-owned `pull_request_target` workflow routes newly opened, updated,
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
does not check out or execute PR code, excludes both the author and the known
latest pusher, and balances the open requested-review load across the reviewed
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.
## Running focused gates locally
Run the contracts relevant to the change. Ordinary contributors are not
expected to repeat every CI job locally:
Run the contracts relevant to the change:
```sh
make build
@@ -173,18 +139,15 @@ base_ref=$(git merge-base HEAD origin/main)
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
```
`make coverage-gate` is an enforcement step, not a profile generator. For a
standard PR, CI derives changed packages and their reverse-dependency test
closure, then generates candidate and merge-base profiles with the same test
scope and `coverpkg`. High-risk and protected-main runs use the complete
profiles. Supporting and (when platform-selected) native profiles are
generated before the aggregate `Coverage` context evaluates them. The
`make coverage-gate` is an enforcement step, not a profile generator. CI
generates the candidate, supporting, merge-base, and (when risk-selected)
native profiles before the aggregate `Coverage` context evaluates them. The
aggregate and native gates require 100% coverage for changed executable Go
statements. Overall coverage remains an unrounded, zero-tolerance,
scope-matched merge-base non-regression check. Candidate and baseline profiles
are evaluated by the same block-deduplicating checker; supporting policy and
shortcut profiles contribute to changed-code coverage only. The checked-in
badge is presentation only and is never read as a gate input.
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
non-regression check. Candidate and baseline profiles are evaluated by the
same block-deduplicating checker; supporting policy and shortcut profiles
contribute to changed-code coverage only. The checked-in badge is presentation
only and is never read as a gate input.
Compatibility checks derive authoritative Interface snapshots from the PR
merge-base and the latest reachable stable release. The candidate cannot bless
@@ -212,7 +175,3 @@ Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
alias. Update ruleset contexts only after the new names have appeared on the
protected branch, so a rename cannot silently remove enforcement or leave an
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.
+10 -10
View File
@@ -1,6 +1,6 @@
# 发布手册(预发 / 正式)
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
@@ -13,7 +13,7 @@
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
@@ -132,14 +132,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
@@ -166,17 +166,17 @@ dws-release recover v1.2.3-beta.1
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 fail closed。
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
## 平台治理前置
@@ -189,9 +189,9 @@ Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
File diff suppressed because it is too large Load Diff
+11 -84
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:bda812c0b48f221a70f9c710d56a4dead5916bf425a9b153e7b275d15365f1a0",
"surface_hash": "sha256:8eef4235b4391a6a865c180ebd3a65ca3531a84c6985b37811f23fff9daf2e3a",
"source_hash": "sha256:be0a967cd36b8430c22dd27fbd9a01ec232d0ce07b8a5ca3f9d35f2554ecbbe4",
"surface_hash": "sha256:2c84c53f5844050980fb5e1459c92e3afbd701357e125608f4c41a43abc9bdd5",
"coverage": {
"surface_products": 25,
"products_with_metadata": 25,
"surface_tools": 603,
"tools_with_metadata": 603,
"tools_with_agent_summary": 603,
"tools_with_use_when": 603,
"tools_with_avoid_when": 603,
"tools_with_examples": 603,
"tools_with_interface_mode": 603,
"surface_products": 24,
"products_with_metadata": 24,
"surface_tools": 592,
"tools_with_metadata": 592,
"tools_with_agent_summary": 592,
"tools_with_use_when": 592,
"tools_with_avoid_when": 592,
"tools_with_examples": 592,
"tools_with_interface_mode": 592,
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
},
@@ -941,78 +941,6 @@
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
]
},
"hrbrain": {
"agent_summary": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
"agent_summary_source": "dws-agent-selection/hrbrain",
"avoid_when": [
"要操作通讯录/组织架构基础信息时改用 contact 产品",
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
],
"field_provenance": {
"agent_summary": {
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
"source": "internal/cli/schema_hints/selection/hrbrain.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
"source": "internal/cli/schema_hints/selection/hrbrain.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
},
"avoid_when": {
"value": [
"要操作通讯录/组织架构基础信息时改用 contact 产品",
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
],
"source": "internal/cli/schema_hints/selection/hrbrain.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": [
"要操作通讯录/组织架构基础信息时改用 contact 产品",
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
],
"source": "internal/cli/schema_hints/selection/hrbrain.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
},
"use_when": {
"value": [
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
],
"source": "internal/cli/schema_hints/selection/hrbrain.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": [
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
],
"source": "internal/cli/schema_hints/selection/hrbrain.json",
"precedence": "reviewed_explicit",
"selected": true
}
]
}
},
"source_refs": [
"CommandRegistry:product=hrbrain",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md",
"internal/cli/schema_hints/selection/hrbrain.json",
"skills/mono/SKILL.md"
],
"use_when": [
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
]
},
"live": {
"agent_summary": "查询当前用户发起的直播列表",
"agent_summary_source": "dws-agent-selection/live",
@@ -1811,7 +1739,6 @@
"doc",
"drive",
"event",
"hrbrain",
"live",
"mail",
"markdown",
+18 -27
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"source_hash": "sha256:bda812c0b48f221a70f9c710d56a4dead5916bf425a9b153e7b275d15365f1a0",
"surface_hash": "sha256:8eef4235b4391a6a865c180ebd3a65ca3531a84c6985b37811f23fff9daf2e3a",
"source_files": 158,
"hint_files": 52,
"hint_products": 42,
"hint_tools": 1358,
"source_hash": "sha256:be0a967cd36b8430c22dd27fbd9a01ec232d0ce07b8a5ca3f9d35f2554ecbbe4",
"surface_hash": "sha256:2c84c53f5844050980fb5e1459c92e3afbd701357e125608f4c41a43abc9bdd5",
"source_files": 155,
"hint_files": 50,
"hint_products": 41,
"hint_tools": 1336,
"interface_metadata": {
"source": "mcp-tools-list+cli-registry",
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
@@ -27,15 +27,15 @@
]
},
"coverage": {
"surface_products": 25,
"products_with_metadata": 25,
"surface_tools": 603,
"tools_with_metadata": 603,
"tools_with_agent_summary": 603,
"tools_with_use_when": 603,
"tools_with_avoid_when": 603,
"tools_with_examples": 603,
"tools_with_interface_mode": 603,
"surface_products": 24,
"products_with_metadata": 24,
"surface_tools": 592,
"tools_with_metadata": 592,
"tools_with_agent_summary": 592,
"tools_with_use_when": 592,
"tools_with_avoid_when": 592,
"tools_with_examples": 592,
"tools_with_interface_mode": 592,
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
},
@@ -58,7 +58,6 @@
"event",
"extension",
"generate-topic-report",
"hrbrain",
"live",
"mail",
"markdown",
@@ -163,7 +162,7 @@
{
"tool_path": "calendar participant delete",
"source": "skills/mono/SKILL.md",
"line": 148,
"line": 145,
"candidates": [
"calendar attendee delete",
"calendar event delete",
@@ -664,7 +663,6 @@
"source": "skills/mono/references/products/agoal.md",
"line": 65,
"candidates": [
"hrbrain talent-pool detail",
"oa approval detail"
],
"review": {
@@ -705,8 +703,7 @@
"source": "skills/mono/references/products/agoal.md",
"line": 70,
"candidates": [
"contact user profile fields",
"hrbrain search fields"
"contact user profile fields"
],
"review": {
"status": "out_of_surface",
@@ -718,7 +715,6 @@
"source": "skills/mono/references/products/agoal.md",
"line": 71,
"candidates": [
"hrbrain talent-pool detail",
"oa approval detail"
],
"review": {
@@ -745,7 +741,6 @@
"source": "skills/mono/references/products/agoal.md",
"line": 75,
"candidates": [
"hrbrain talent-pool detail",
"oa approval detail"
],
"review": {
@@ -866,7 +861,6 @@
"source": "skills/mono/references/products/agoal.md",
"line": 97,
"candidates": [
"hrbrain talent-pool detail",
"oa approval detail"
],
"review": {
@@ -907,8 +901,7 @@
"source": "skills/mono/references/products/agoal.md",
"line": 106,
"candidates": [
"contact user profile fields",
"hrbrain search fields"
"contact user profile fields"
],
"review": {
"status": "out_of_surface",
@@ -920,7 +913,6 @@
"source": "skills/mono/references/products/agoal.md",
"line": 107,
"candidates": [
"hrbrain talent-pool detail",
"oa approval detail"
],
"review": {
@@ -947,7 +939,6 @@
"source": "skills/mono/references/products/agoal.md",
"line": 115,
"candidates": [
"hrbrain talent-pool detail",
"oa approval detail"
],
"review": {
File diff suppressed because it is too large Load Diff
-49
View File
@@ -1602,55 +1602,6 @@
}
]
},
{
"id": "hrbrain",
"tools": [
{
"canonical_path": "hrbrain.list_talent_pools",
"cli_path": "hrbrain talent-pool list"
},
{
"canonical_path": "hrbrain.get_talent_pool_detail",
"cli_path": "hrbrain talent-pool detail"
},
{
"canonical_path": "hrbrain.list_pool_employees",
"cli_path": "hrbrain talent-pool employees"
},
{
"canonical_path": "hrbrain.get_profile_metadata",
"cli_path": "hrbrain profile metadata"
},
{
"canonical_path": "hrbrain.query_profile_data",
"cli_path": "hrbrain profile query"
},
{
"canonical_path": "hrbrain.get_profile_label",
"cli_path": "hrbrain profile labels"
},
{
"canonical_path": "hrbrain.get_employee_career",
"cli_path": "hrbrain profile career"
},
{
"canonical_path": "hrbrain.get_employee_performance",
"cli_path": "hrbrain profile performance"
},
{
"canonical_path": "hrbrain.search_employees",
"cli_path": "hrbrain search employees"
},
{
"canonical_path": "hrbrain.search_employees_structured",
"cli_path": "hrbrain search employees-structured"
},
{
"canonical_path": "hrbrain.get_search_fields",
"cli_path": "hrbrain search fields"
}
]
},
{
"id": "live",
"tools": [
-2
View File
@@ -21,7 +21,6 @@
"doc": "metadata/doc.json",
"drive": "metadata/drive.json",
"event": "metadata/event.json",
"hrbrain": "metadata/hrbrain.json",
"live": "metadata/live.json",
"mail": "metadata/mail.json",
"markdown": "metadata/markdown.json",
@@ -48,7 +47,6 @@
"doc": "selection/doc.json",
"drive": "selection/drive.json",
"event": "selection/event.json",
"hrbrain": "selection/hrbrain.json",
"live": "selection/live.json",
"mail": "selection/mail.json",
"markdown": "selection/markdown.json",
@@ -1,289 +0,0 @@
{
"version": 1,
"source": {
"kind": "explicit",
"name": "dws-tool-metadata/hrbrain",
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
"channel": "open-source",
"reviewed": true
},
"tools": {
"hrbrain.list_talent_pools": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool list_talent_pools, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain talent-pool list",
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the list_talent_pools MCP call arguments observed in internal/helpers/hrbrain.go.",
"parameters": {
"keyword": {
"property": "keyword",
"required": false
},
"pool-type": {
"property": "poolType",
"required": false
},
"creator": {
"property": "creator",
"required": false
},
"labels": {
"property": "labels",
"required": false
},
"page": {
"property": "currentPage",
"required": false
},
"page-size": {
"property": "pageSize",
"required": false
}
}
},
"hrbrain.get_talent_pool_detail": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_talent_pool_detail, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain talent-pool detail",
"review_reason": "Reviewed unpinned remote adapter: --pool-code maps 1:1 to the poolCode MCP call argument observed in internal/helpers/hrbrain.go.",
"parameters": {
"pool-code": {
"property": "poolCode",
"required": true
}
}
},
"hrbrain.list_pool_employees": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool list_pool_employees, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain talent-pool employees",
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the list_pool_employees MCP call arguments observed in internal/helpers/hrbrain.go.",
"parameters": {
"pool-code": {
"property": "poolCode",
"required": true
},
"page": {
"property": "currentPage",
"required": false
},
"page-size": {
"property": "pageSize",
"required": false
}
}
},
"hrbrain.get_profile_metadata": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_profile_metadata, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain profile metadata",
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
"parameters": {
"work-no": {
"property": "workNo",
"required": true
}
}
},
"hrbrain.query_profile_data": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool query_profile_data, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain profile query",
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the query_profile_data MCP call arguments observed in internal/helpers/hrbrain.go.",
"parameters": {
"work-no": {
"property": "workNo",
"required": true
},
"data-queries": {
"property": "dataQueries",
"required": true
}
}
},
"hrbrain.get_profile_label": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_profile_label, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain profile labels",
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the get_profile_label MCP call arguments observed in internal/helpers/hrbrain.go.",
"parameters": {
"staff-ids": {
"property": "staffIds",
"required": true
},
"all-label": {
"property": "allLabel",
"required": false
}
}
},
"hrbrain.get_employee_career": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_employee_career, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain profile career",
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
"parameters": {
"work-no": {
"property": "workNo",
"required": true
}
}
},
"hrbrain.get_employee_performance": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_employee_performance, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain profile performance",
"review_reason": "Reviewed unpinned remote adapter: --work-no maps 1:1 to the workNo MCP call argument observed in internal/helpers/hrbrain.go.",
"parameters": {
"work-no": {
"property": "workNo",
"required": true
}
}
},
"hrbrain.search_employees": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool search_employees, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain search employees",
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the search_employees MCP call arguments observed in internal/helpers/hrbrain.go.",
"parameters": {
"keyword": {
"property": "keyword",
"required": false
},
"dept-name": {
"property": "deptName",
"required": false
},
"position-name": {
"property": "positionName",
"required": false
},
"job-level": {
"property": "jobLevel",
"required": false
},
"pool-code": {
"property": "poolCode",
"required": false
},
"page": {
"property": "currentPage",
"required": false
},
"page-size": {
"property": "pageSize",
"required": false
}
}
},
"hrbrain.search_employees_structured": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool search_employees_structured, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true,
"cli_path": "hrbrain search employees-structured",
"review_reason": "Reviewed unpinned remote adapter: flags map 1:1 to the search_employees_structured MCP call arguments observed in internal/helpers/hrbrain.go.",
"parameters": {
"origin-json": {
"property": "originJson",
"required": true
},
"fields": {
"property": "fields",
"required": true
},
"order-by": {
"property": "orderByClauses",
"required": false
},
"page": {
"property": "currentPage",
"required": false
},
"page-size": {
"property": "pageSize",
"required": false
}
}
},
"hrbrain.get_search_fields": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"runtime_gate": "none",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls the hrbrain MCP tool get_search_fields, which is absent from the pinned MCP metadata snapshot; no single pinned interface_ref can represent the command.",
"reviewed": true
}
}
}
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 603,
"source_tools": 592,
"matched_tools": 71
},
"tools": {
@@ -1,258 +0,0 @@
{
"version": 1,
"source": {
"kind": "explicit",
"name": "dws-agent-selection/hrbrain",
"repository": "DingTalk-Real-AI/dingtalk-workspace-cli",
"channel": "open-source",
"reviewed": true
},
"tools": {
"hrbrain.list_talent_pools": {
"agent_summary": "查询人才池列表,支持按名称关键词、类型、创建人、标签筛选",
"use_when": [
"需要列出组织中的人才池,或按名称/类型/创建人/标签筛选人才池时"
],
"avoid_when": [
"已知 poolCode 只需要单个人才池详情时改用 dws hrbrain talent-pool detail",
"要查看人才池内人员名单时改用 dws hrbrain talent-pool employees"
],
"examples": [
"dws hrbrain talent-pool list --page 1 --page-size 20",
"dws hrbrain talent-pool list --keyword \"储备干部\""
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.list_talent_pools",
"cobra-help:dws hrbrain talent-pool list",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.get_talent_pool_detail": {
"agent_summary": "根据人才池编码获取人才池详细信息",
"use_when": [
"已知 poolCode,需要查看某个人才池的详细信息时"
],
"avoid_when": [
"尚未取得 poolCode 时先用 dws hrbrain talent-pool list 查找"
],
"examples": [
"dws hrbrain talent-pool detail --pool-code POOL_CODE"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.get_talent_pool_detail",
"cobra-help:dws hrbrain talent-pool detail",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.list_pool_employees": {
"agent_summary": "查询指定人才池内的人员列表",
"use_when": [
"已知 poolCode,需要查看该人才池内的人员名单时"
],
"avoid_when": [
"尚未取得 poolCode 时先用 dws hrbrain talent-pool list 查找",
"不限定人才池的人员搜索请改用 dws hrbrain search employees"
],
"examples": [
"dws hrbrain talent-pool employees --pool-code POOL_CODE --page 1 --page-size 20"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.list_pool_employees",
"cobra-help:dws hrbrain talent-pool employees",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.get_profile_metadata": {
"agent_summary": "查询员工档案元数据结构,用于构造档案数据查询参数",
"use_when": [
"需要先了解某员工档案有哪些模块/字段,以构造 hrbrain profile query 的 --data-queries 参数时"
],
"avoid_when": [
"已知模块与字段编码,直接查询档案数据时改用 dws hrbrain profile query"
],
"examples": [
"dws hrbrain profile metadata --work-no WORK_NO"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.get_profile_metadata",
"cobra-help:dws hrbrain profile metadata",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.query_profile_data": {
"agent_summary": "按模块批量查询员工档案数据",
"use_when": [
"已知模块与字段编码(通常先用 hrbrain profile metadata 获取),需要批量查询员工档案数据时"
],
"avoid_when": [
"尚不清楚可查询的模块/字段时先用 dws hrbrain profile metadata",
"只需要职业历程或绩效记录时改用 dws hrbrain profile career / profile performance"
],
"examples": [
"dws hrbrain profile query --work-no WORK_NO --data-queries '[{\"modelCode\":\"basic\",\"fields\":[\"name\",\"dept\"]}]'"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.query_profile_data",
"cobra-help:dws hrbrain profile query",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.get_profile_label": {
"agent_summary": "根据员工工号列表获取员工标签",
"use_when": [
"需要按一个或多个工号批量查看员工标签时"
],
"avoid_when": [
"要查看职业历程或绩效记录时改用 dws hrbrain profile career / profile performance"
],
"examples": [
"dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.get_profile_label",
"cobra-help:dws hrbrain profile labels",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.get_employee_career": {
"agent_summary": "查询员工在公司内的职业历程",
"use_when": [
"需要查看某员工在公司内的岗位/职级变动历史时"
],
"avoid_when": [
"要查看绩效记录时改用 dws hrbrain profile performance"
],
"examples": [
"dws hrbrain profile career --work-no WORK_NO"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.get_employee_career",
"cobra-help:dws hrbrain profile career",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.get_employee_performance": {
"agent_summary": "查询员工绩效记录",
"use_when": [
"需要查看某员工的历史绩效评级/记录时"
],
"avoid_when": [
"要查看职业历程时改用 dws hrbrain profile career"
],
"examples": [
"dws hrbrain profile performance --work-no WORK_NO"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.get_employee_performance",
"cobra-help:dws hrbrain profile performance",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.search_employees": {
"agent_summary": "按关键词、部门、职务、职级、人才池等条件搜索员工",
"use_when": [
"需要按姓名/工号/部门/职务/职级等基础条件模糊找人时"
],
"avoid_when": [
"需要复杂组合条件(表达式)搜索时改用 dws hrbrain search employees-structured",
"已限定某个人才池要看全部人员时改用 dws hrbrain talent-pool employees"
],
"examples": [
"dws hrbrain search employees --keyword \"张三\" --page 1 --page-size 20",
"dws hrbrain search employees --dept-name \"技术部\" --job-level P7"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.search_employees",
"cobra-help:dws hrbrain search employees",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.search_employees_structured": {
"agent_summary": "使用高级条件表达式(originJson)搜索员工",
"use_when": [
"需要使用组合过滤条件表达式搜索员工,且已通过 dws hrbrain search fields 获取有权限的字段/操作符时"
],
"avoid_when": [
"只是简单关键词/部门/职级搜索时改用 dws hrbrain search employees",
"尚未获取可用字段与操作符时先用 dws hrbrain search fields"
],
"examples": [
"dws hrbrain search employees-structured --origin-json '{\"rules\":[{\"field\":\"name\",\"operator\":\"contains\",\"value\":\"张\"}],\"combinator\":\"and\"}' --fields '[{\"label\":\"姓名\",\"value\":\"name\"}]'"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.search_employees_structured",
"cobra-help:dws hrbrain search employees-structured",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
},
"hrbrain.get_search_fields": {
"agent_summary": "获取当前操作人有权限使用的高级搜索字段与操作符列表",
"use_when": [
"在调用 dws hrbrain search employees-structured 之前,需要确认可用字段与操作符时"
],
"avoid_when": [
"只是简单关键词搜索时改用 dws hrbrain search employees"
],
"examples": [
"dws hrbrain search fields"
],
"reviewed": true,
"review_reason": "依据 reviewed CommandRegistry、真实 Cobra help 与 mono/multi Skill 选型审阅。",
"source_refs": [
"CommandRegistry:canonical_path=hrbrain.get_search_fields",
"cobra-help:dws hrbrain search fields",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
}
},
"products": {
"hrbrain": {
"agent_summary": "钉钉组织大脑:人才池管理、员工档案查询与人才搜索",
"use_when": [
"需要查询人才池、员工档案(元数据/标签/职业历程/绩效)或搜索员工时"
],
"avoid_when": [
"要操作通讯录/组织架构基础信息时改用 contact 产品",
"要提交/查询战略解码、经营合约、目标等 OKR 能力时改用 agoal(CLI-only,未接入 Agent Schema)"
],
"reviewed": true,
"review_reason": "依据真实 CommandRegistry 命令分区与对应产品 Skill 审阅产品级选择边界。",
"source_refs": [
"CommandRegistry:product=hrbrain",
"Skill:skills/mono/references/products/hrbrain.md",
"Skill:skills/multi/dingtalk-hrbrain/SKILL.md"
]
}
}
}
-1
View File
@@ -99,7 +99,6 @@ var cmdToProduct = map[string]string{
"sheet": "sheet",
"wiki": "wiki",
"aisearch": "aisearch",
"hrbrain": "hrbrain",
"yida": "yida",
// vendor extension command routing (kept here for resolveProductID)
"unified-toolkit": "unified-toolkit",
-321
View File
@@ -1,321 +0,0 @@
package helpers
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// dws hrbrain — 组织大脑(人才池、员工档案、人才搜索)
// ──────────────────────────────────────────────────────────
func newHrbrainCommand() *cobra.Command {
root := &cobra.Command{
Use: "hrbrain",
Short: "组织大脑:人才池、员工档案与人才搜索",
Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。
命令结构:
dws hrbrain talent-pool list 人才池列表
dws hrbrain talent-pool detail 获取人才池详情
dws hrbrain talent-pool employees 人才池人员列表
dws hrbrain profile metadata 查询员工档案元数据结构
dws hrbrain profile query 按模块批量查询员工档案数据
dws hrbrain profile labels 获取员工标签
dws hrbrain profile career 查询员工公司内职业历程
dws hrbrain profile performance 查询员工绩效记录
dws hrbrain search employees 人才搜索
dws hrbrain search employees-structured 使用高级条件搜索人员
dws hrbrain search fields 获取高级搜索字段列表`,
RunE: groupRunE,
}
// ── talent-pool: 人才池管理 ────────────────────────────────
talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}
talentPoolListCmd := &cobra.Command{
Use: "list",
Short: "人才池列表",
Long: `查询人才池列表,支持按名称关键词、类型、创建人、标签等条件筛选。`,
Example: ` dws hrbrain talent-pool list --page 1 --page-size 20
dws hrbrain talent-pool list --keyword "储备干部" --pool-type TYPE --creator USER_ID`,
RunE: func(cmd *cobra.Command, args []string) error {
page, _ := cmd.Flags().GetInt("page")
pageSize, _ := cmd.Flags().GetInt("page-size")
toolArgs := map[string]any{
"currentPage": page,
"pageSize": pageSize,
}
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
toolArgs["keyword"] = v
}
if v, _ := cmd.Flags().GetString("pool-type"); v != "" {
toolArgs["poolType"] = v
}
if v, _ := cmd.Flags().GetString("creator"); v != "" {
toolArgs["creator"] = v
}
if v, _ := cmd.Flags().GetString("labels"); v != "" {
toolArgs["labels"] = parseCSVValues(v)
}
return callMCPTool("list_talent_pools", toolArgs)
},
}
talentPoolListCmd.Flags().String("keyword", "", "人才池名称关键词 (可选)")
talentPoolListCmd.Flags().String("pool-type", "", "人才池类型 (可选)")
talentPoolListCmd.Flags().String("creator", "", "创建人 (可选)")
talentPoolListCmd.Flags().String("labels", "", "标签列表,逗号分隔 (可选)")
talentPoolListCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
talentPoolListCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
talentPoolDetailCmd := &cobra.Command{
Use: "detail",
Short: "获取人才池详情",
Long: `根据人才池编码获取人才池详细信息。`,
Example: ` dws hrbrain talent-pool detail --pool-code POOL_CODE`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "pool-code"); err != nil {
return err
}
return callMCPTool("get_talent_pool_detail", map[string]any{
"poolCode": mustGetFlag(cmd, "pool-code"),
})
},
}
talentPoolDetailCmd.Flags().String("pool-code", "", "人才池编码 (必填)")
talentPoolEmployeesCmd := &cobra.Command{
Use: "employees",
Short: "人才池人员列表",
Long: `查询指定人才池内的人员列表。`,
Example: ` dws hrbrain talent-pool employees --pool-code POOL_CODE --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "pool-code"); err != nil {
return err
}
page, _ := cmd.Flags().GetInt("page")
pageSize, _ := cmd.Flags().GetInt("page-size")
return callMCPTool("list_pool_employees", map[string]any{
"poolCode": mustGetFlag(cmd, "pool-code"),
"currentPage": page,
"pageSize": pageSize,
})
},
}
talentPoolEmployeesCmd.Flags().String("pool-code", "", "人才池编码 (必填)")
talentPoolEmployeesCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
talentPoolEmployeesCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
talentPoolCmd.AddCommand(talentPoolListCmd, talentPoolDetailCmd, talentPoolEmployeesCmd)
// ── profile: 员工档案管理 ──────────────────────────────────
profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}
profileMetadataCmd := &cobra.Command{
Use: "metadata",
Short: "查询员工档案元数据结构",
Long: `查询指定员工档案的元数据结构,用于构造 query_profile_data 的 dataQueries 参数。`,
Example: ` dws hrbrain profile metadata --work-no WORK_NO`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "work-no"); err != nil {
return err
}
return callMCPTool("get_profile_metadata", map[string]any{
"workNo": mustGetFlag(cmd, "work-no"),
})
},
}
profileMetadataCmd.Flags().String("work-no", "", "员工工号 (必填)")
profileQueryCmd := &cobra.Command{
Use: "query",
Short: "按模块批量查询员工档案数据",
Long: `按模块维度批量查询员工档案数据。
--data-queries 为 JSON 数组,每个元素包含:
modelCode — 档案模块编码
fields — 要查询的字段编码列表`,
Example: ` dws hrbrain profile query --work-no WORK_NO --data-queries '[{"modelCode":"basic","fields":["name","dept"]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "work-no", "data-queries"); err != nil {
return err
}
var queries []any
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "data-queries")), &queries); err != nil {
return fmt.Errorf("--data-queries must be a valid JSON array: %w", err)
}
if len(queries) == 0 {
return fmt.Errorf("--data-queries must be a non-empty JSON array")
}
return callMCPTool("query_profile_data", map[string]any{
"workNo": mustGetFlag(cmd, "work-no"),
"dataQueries": queries,
})
},
}
profileQueryCmd.Flags().String("work-no", "", "目标员工工号 (必填)")
profileQueryCmd.Flags().String("data-queries", "", "按模块查询的条件列表 JSON 数组 (必填)")
profileLabelsCmd := &cobra.Command{
Use: "labels",
Short: "获取员工标签",
Long: `根据员工工号列表获取员工标签。`,
Example: ` dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2 --all-label`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "staff-ids"); err != nil {
return err
}
toolArgs := map[string]any{
"staffIds": parseCSVValues(mustGetFlag(cmd, "staff-ids")),
}
if cmd.Flags().Changed("all-label") {
v, _ := cmd.Flags().GetBool("all-label")
toolArgs["allLabel"] = v
}
return callMCPTool("get_profile_label", toolArgs)
},
}
profileLabelsCmd.Flags().String("staff-ids", "", "员工工号列表,逗号分隔 (必填)")
profileLabelsCmd.Flags().Bool("all-label", false, "是否所有标签 (可选)")
profileCareerCmd := &cobra.Command{
Use: "career",
Short: "查询员工公司内职业历程",
Example: ` dws hrbrain profile career --work-no WORK_NO`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "work-no"); err != nil {
return err
}
return callMCPTool("get_employee_career", map[string]any{
"workNo": mustGetFlag(cmd, "work-no"),
})
},
}
profileCareerCmd.Flags().String("work-no", "", "员工工号 (必填)")
profilePerformanceCmd := &cobra.Command{
Use: "performance",
Short: "查询员工绩效记录",
Example: ` dws hrbrain profile performance --work-no WORK_NO`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "work-no"); err != nil {
return err
}
return callMCPTool("get_employee_performance", map[string]any{
"workNo": mustGetFlag(cmd, "work-no"),
})
},
}
profilePerformanceCmd.Flags().String("work-no", "", "员工工号 (必填)")
profileCmd.AddCommand(profileMetadataCmd, profileQueryCmd, profileLabelsCmd, profileCareerCmd, profilePerformanceCmd)
// ── search: 人才搜索 ─────────────────────────────────────
searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}
employeeSearchCmd := &cobra.Command{
Use: "employees",
Short: "人才搜索",
Long: `按关键词、部门、职务、职级、人才池等条件搜索人员。`,
Example: ` dws hrbrain search employees --keyword "张三" --page 1 --page-size 20
dws hrbrain search employees --dept-name "技术部" --job-level P7 --pool-code POOL_CODE`,
RunE: func(cmd *cobra.Command, args []string) error {
page, _ := cmd.Flags().GetInt("page")
pageSize, _ := cmd.Flags().GetInt("page-size")
toolArgs := map[string]any{
"currentPage": page,
"pageSize": pageSize,
}
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
toolArgs["keyword"] = v
}
if v, _ := cmd.Flags().GetString("dept-name"); v != "" {
toolArgs["deptName"] = v
}
if v, _ := cmd.Flags().GetString("position-name"); v != "" {
toolArgs["positionName"] = v
}
if v, _ := cmd.Flags().GetString("job-level"); v != "" {
toolArgs["jobLevel"] = v
}
if v, _ := cmd.Flags().GetString("pool-code"); v != "" {
toolArgs["poolCode"] = v
}
return callMCPTool("search_employees", toolArgs)
},
}
employeeSearchCmd.Flags().String("keyword", "", "全文搜索关键词(姓名/工号等)(可选)")
employeeSearchCmd.Flags().String("dept-name", "", "部门名称 (可选)")
employeeSearchCmd.Flags().String("position-name", "", "职务名称 (可选)")
employeeSearchCmd.Flags().String("job-level", "", "职级 (可选)")
employeeSearchCmd.Flags().String("pool-code", "", "限定人才池编码 (可选)")
employeeSearchCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
employeeSearchCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
employeeSearchStructuredCmd := &cobra.Command{
Use: "employees-structured",
Short: "使用高级条件搜索人员",
Long: `使用高级条件(originJson 表达式)搜索人员。
建议先调用 "dws hrbrain search fields" 获取有权限的字段与操作符列表。
--origin-json 为 JSON 字符串,例如:
{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}
--fields 为 JSON 数组,例如:
[{"label":"姓名","value":"name"}]
--order-by 为逗号分隔的排序字段列表 (可选)`,
Example: ` dws hrbrain search employees-structured --origin-json '{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}' --fields '[{"label":"姓名","value":"name"}]' --page 1 --page-size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "origin-json", "fields"); err != nil {
return err
}
page, _ := cmd.Flags().GetInt("page")
pageSize, _ := cmd.Flags().GetInt("page-size")
originJSON := mustGetFlag(cmd, "origin-json")
var originObj map[string]any
if err := json.Unmarshal([]byte(originJSON), &originObj); err != nil {
return fmt.Errorf("--origin-json must be a valid JSON object: %w", err)
}
var fields []any
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "fields")), &fields); err != nil {
return fmt.Errorf("--fields must be a valid JSON array: %w", err)
}
if len(fields) == 0 {
return fmt.Errorf("--fields must be a non-empty JSON array")
}
toolArgs := map[string]any{
"originJson": originJSON,
"currentPage": page,
"pageSize": pageSize,
"fields": fields,
}
if v, _ := cmd.Flags().GetString("order-by"); v != "" {
toolArgs["orderByClauses"] = parseCSVValues(v)
}
return callMCPTool("search_employees_structured", toolArgs)
},
}
employeeSearchStructuredCmd.Flags().String("origin-json", "", "搜索条件 JSON 表达式 (必填)")
employeeSearchStructuredCmd.Flags().Int("page", 1, "当前页码 (默认 1)")
employeeSearchStructuredCmd.Flags().Int("page-size", 20, "每页条数 (默认 20)")
employeeSearchStructuredCmd.Flags().String("order-by", "", "排序字段列表,逗号分隔 (可选)")
employeeSearchStructuredCmd.Flags().String("fields", "", "返回列定义 JSON 数组 (必填)")
searchFieldsCmd := &cobra.Command{
Use: "fields",
Short: "获取高级搜索字段列表",
Long: `获取当前操作人有权限使用的高级搜索字段列表,用于构造 employees-structured 的参数。`,
Example: ` dws hrbrain search fields`,
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPTool("get_search_fields", nil)
},
}
searchCmd.AddCommand(employeeSearchCmd, employeeSearchStructuredCmd, searchFieldsCmd)
root.AddCommand(talentPoolCmd, profileCmd, searchCmd)
return root
}
-332
View File
@@ -1,332 +0,0 @@
package helpers
import (
"io"
"os"
"testing"
"github.com/spf13/cobra"
)
// executeHrbrainCommand runs the given hrbrain command tree with args, discarding
// output, and returns the error (if any) from RunE.
func executeHrbrainCommand(t *testing.T, root *cobra.Command, args ...string) error {
t.Helper()
oldArgs := os.Args
os.Args = append([]string{"dws", "hrbrain"}, args...)
t.Cleanup(func() { os.Args = oldArgs })
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
// SetArgs must receive a non-nil slice: when args is nil (zero variadic
// call), cobra falls back to os.Args[1:] internally, which would wrongly
// feed the literal "hrbrain" token back in as a bogus positional arg.
root.SetArgs(append([]string{}, args...))
return root.Execute()
}
func TestHrbrainTalentPoolList(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"talent-pool", "list",
); err != nil {
t.Fatalf("list without optional flags: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"talent-pool", "list",
"--page", "1", "--page-size", "20",
"--keyword", "储备干部", "--pool-type", "TYPE", "--creator", "USER_ID",
"--labels", "a,b,c",
); err != nil {
t.Fatalf("list with optional flags: %v", err)
}
}
func TestHrbrainTalentPoolDetail(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"talent-pool", "detail", "--pool-code", "POOL_CODE",
); err != nil {
t.Fatalf("detail with pool-code: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"talent-pool", "detail",
); err == nil {
t.Fatal("detail without pool-code should error")
}
}
func TestHrbrainTalentPoolEmployees(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"talent-pool", "employees",
"--pool-code", "POOL_CODE", "--page", "1", "--page-size", "20",
); err != nil {
t.Fatalf("employees with pool-code: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"talent-pool", "employees",
); err == nil {
t.Fatal("employees without pool-code should error")
}
}
func TestHrbrainProfileMetadata(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "metadata", "--work-no", "WORK_NO",
); err != nil {
t.Fatalf("metadata with work-no: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "metadata",
); err == nil {
t.Fatal("metadata without work-no should error")
}
}
func TestHrbrainProfileQuery(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "query",
"--work-no", "WORK_NO",
"--data-queries", `[{"modelCode":"basic","fields":["name","dept"]}]`,
); err != nil {
t.Fatalf("query with valid data-queries: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "query",
"--work-no", "WORK_NO",
"--data-queries", `not-json`,
); err == nil {
t.Fatal("query with invalid data-queries JSON should error")
}
// An empty JSON array is syntactically valid but queries nothing, so it
// must be rejected before dispatch.
emptyDataQueriesCaller := &scriptedToolCaller{dry: true}
installScriptedCaller(t, emptyDataQueriesCaller)
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "query",
"--work-no", "WORK_NO",
"--data-queries", `[]`,
); err == nil {
t.Fatal("query with empty data-queries array should error")
}
if emptyDataQueriesCaller.calls != 0 {
t.Fatalf("query with empty data-queries must not call MCP, got %d call(s)", emptyDataQueriesCaller.calls)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "query", "--work-no", "WORK_NO",
); err == nil {
t.Fatal("query without data-queries should error")
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "query", "--data-queries", `[]`,
); err == nil {
t.Fatal("query without work-no should error")
}
}
func TestHrbrainProfileLabels(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "labels", "--staff-ids", "WORK_NO1,WORK_NO2", "--all-label",
); err != nil {
t.Fatalf("labels with staff-ids and all-label: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "labels", "--staff-ids", "WORK_NO1",
); err != nil {
t.Fatalf("labels without all-label: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "labels",
); err == nil {
t.Fatal("labels without staff-ids should error")
}
}
func TestHrbrainProfileCareer(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "career", "--work-no", "WORK_NO",
); err != nil {
t.Fatalf("career with work-no: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "career",
); err == nil {
t.Fatal("career without work-no should error")
}
}
func TestHrbrainProfilePerformance(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "performance", "--work-no", "WORK_NO",
); err != nil {
t.Fatalf("performance with work-no: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"profile", "performance",
); err == nil {
t.Fatal("performance without work-no should error")
}
}
func TestHrbrainSearchEmployees(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees",
); err != nil {
t.Fatalf("search employees without optional flags: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees",
"--keyword", "张三", "--dept-name", "技术部", "--position-name", "工程师",
"--job-level", "P7", "--pool-code", "POOL_CODE",
"--page", "1", "--page-size", "20",
); err != nil {
t.Fatalf("search employees with all optional flags: %v", err)
}
}
func TestHrbrainSearchEmployeesStructured(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured",
"--origin-json", `{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}`,
"--fields", `[{"label":"姓名","value":"name"}]`,
"--order-by", "name,dept",
"--page", "1", "--page-size", "20",
); err != nil {
t.Fatalf("search employees-structured with valid args: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured",
"--origin-json", `{}`,
"--fields", `not-json`,
); err == nil {
t.Fatal("search employees-structured with invalid fields JSON should error")
}
invalidOriginJSONCaller := &scriptedToolCaller{dry: true}
installScriptedCaller(t, invalidOriginJSONCaller)
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured",
"--origin-json", `not-json`,
"--fields", `[]`,
); err == nil {
t.Fatal("search employees-structured with invalid origin-json JSON should error")
}
if invalidOriginJSONCaller.calls != 0 {
t.Fatalf("search employees-structured with invalid origin-json must not call MCP, got %d call(s)", invalidOriginJSONCaller.calls)
}
// A bare JSON scalar (e.g. a number) is valid JSON per json.Valid, but it
// is not a JSON object, so it must still be rejected before dispatch.
nonObjectOriginJSONCaller := &scriptedToolCaller{dry: true}
installScriptedCaller(t, nonObjectOriginJSONCaller)
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured",
"--origin-json", `111`,
"--fields", `[]`,
); err == nil {
t.Fatal("search employees-structured with non-object origin-json JSON should error")
}
if nonObjectOriginJSONCaller.calls != 0 {
t.Fatalf("search employees-structured with non-object origin-json must not call MCP, got %d call(s)", nonObjectOriginJSONCaller.calls)
}
// An empty --fields array is syntactically valid but selects no columns,
// so it must be rejected before dispatch.
emptyFieldsCaller := &scriptedToolCaller{dry: true}
installScriptedCaller(t, emptyFieldsCaller)
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured",
"--origin-json", `{"rules":[],"combinator":"and"}`,
"--fields", `[]`,
); err == nil {
t.Fatal("search employees-structured with empty fields array should error")
}
if emptyFieldsCaller.calls != 0 {
t.Fatalf("search employees-structured with empty fields must not call MCP, got %d call(s)", emptyFieldsCaller.calls)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured", "--fields", `[]`,
); err == nil {
t.Fatal("search employees-structured without origin-json should error")
}
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "employees-structured", "--origin-json", `{}`,
); err == nil {
t.Fatal("search employees-structured without fields should error")
}
}
func TestHrbrainSearchFields(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
if err := executeHrbrainCommand(t, newHrbrainCommand(),
"search", "fields",
); err != nil {
t.Fatalf("search fields: %v", err)
}
}
func TestHrbrainGroupCommandsWiring(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
root := newHrbrainCommand()
if err := executeHrbrainCommand(t, root); err != nil {
t.Fatalf("hrbrain root with no args should show help: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(), "talent-pool"); err != nil {
t.Fatalf("talent-pool group with no args should show help: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(), "talent-pool", "bogus"); err == nil {
t.Fatal("talent-pool group with unknown subcommand should error")
}
if err := executeHrbrainCommand(t, newHrbrainCommand(), "profile"); err != nil {
t.Fatalf("profile group with no args should show help: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(), "profile", "bogus"); err == nil {
t.Fatal("profile group with unknown subcommand should error")
}
if err := executeHrbrainCommand(t, newHrbrainCommand(), "search"); err != nil {
t.Fatalf("search group with no args should show help: %v", err)
}
if err := executeHrbrainCommand(t, newHrbrainCommand(), "search", "bogus"); err == nil {
t.Fatal("search group with unknown subcommand should error")
}
}
+6 -4
View File
@@ -27,7 +27,9 @@ import (
"github.com/spf13/cobra"
)
var markdownUploadStat = os.Stat
// markdownStatUpload stats the prepared upload file. Tests override it to
// deterministically simulate the file disappearing between write and stat.
var markdownStatUpload = os.Stat
func newMarkdownCommand() *cobra.Command {
root := &cobra.Command{
@@ -259,7 +261,7 @@ func runMarkdownCreate(cmd *cobra.Command, _ []string) error {
if !hasMarkdownExtension(nameFlag) {
return fmt.Errorf("--name 必须以 .md 结尾,当前: %s", nameFlag)
}
info, err := markdownUploadStat(uploadPath)
info, err := markdownStatUpload(uploadPath)
if err != nil {
return fmt.Errorf("读取上传文件失败: %w", err)
}
@@ -423,7 +425,7 @@ func runMarkdownOverwrite(cmd *cobra.Command, _ []string) error {
if !hasMarkdownExtension(nameFlag) {
return fmt.Errorf("--name 必须以 .md 结尾,当前: %s", nameFlag)
}
info, err := markdownUploadStat(uploadPath)
info, err := markdownStatUpload(uploadPath)
if err != nil {
return fmt.Errorf("读取上传文件失败: %w", err)
}
@@ -565,7 +567,7 @@ func runMarkdownPatch(cmd *cobra.Command, _ []string) error {
if err := os.WriteFile(uploadPath, []byte(newContent), 0o600); err != nil {
return fmt.Errorf("写入临时文件失败: %w", err)
}
info, err := markdownUploadStat(uploadPath)
info, err := markdownStatUpload(uploadPath)
if err != nil {
return fmt.Errorf("读取临时文件失败: %w", err)
}
+14 -52
View File
@@ -24,58 +24,20 @@ func (markdownCIFailingReader) Read([]byte) (int, error) {
return 0, errors.New("forced read failure")
}
func installMarkdownCITempFileDisappearance(t *testing.T) {
// installMarkdownStatUploadRemover overrides the markdownStatUpload seam so
// the prepared upload file is deterministically removed before stat runs,
// simulating the file disappearing between write and stat without racing a
// background goroutine.
func installMarkdownStatUploadRemover(t *testing.T) {
t.Helper()
original := markdownUploadStat
markdownUploadStat = func(path string) (os.FileInfo, error) {
if err := os.Remove(path); err != nil {
return nil, err
original := markdownStatUpload
markdownStatUpload = func(name string) (os.FileInfo, error) {
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
t.Errorf("removing upload file %q: %v", name, err)
}
return os.Stat(path)
return os.Stat(name)
}
t.Cleanup(func() { markdownUploadStat = original })
}
func TestCrossPlatformCoverageMarkdownUploadStatFailures(t *testing.T) {
t.Run("create", func(t *testing.T) {
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
installMarkdownCITempFileDisappearance(t)
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
"markdown", "create", "--name", "a.md", "--content", "body")
if err == nil || !strings.Contains(err.Error(), "读取上传文件失败") {
t.Fatalf("error = %v", err)
}
})
t.Run("overwrite", func(t *testing.T) {
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
installMarkdownCITempFileDisappearance(t)
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
"markdown", "overwrite", "--node", "node-1", "--space-id", "space-1",
"--name", "a.md", "--content", "body")
if err == nil || !strings.Contains(err.Error(), "读取上传文件失败") {
t.Fatalf("error = %v", err)
}
})
t.Run("patch", func(t *testing.T) {
caller := &markdownDriveCaller{
format: "json",
steps: []markdownDriveStep{
{text: `{"downloadUrl":"https://download.test/current.md","fileName":"current.md"}`},
{text: `{"fileName":"current.md"}`},
},
}
installMarkdownDriveDeps(t, caller)
installMarkdownHTTPGet(t, "old")
installMarkdownCITempFileDisappearance(t)
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
"markdown", "patch", "--node", "node-1", "--space-id", "space-1",
"--pattern", "old", "--content", "new", "--yes")
if err == nil || !strings.Contains(err.Error(), "读取临时文件失败") {
t.Fatalf("error = %v", err)
}
})
t.Cleanup(func() { markdownStatUpload = original })
}
func TestMarkdownCICoverageFetchAndOutputPaths(t *testing.T) {
@@ -198,7 +160,7 @@ func TestMarkdownCICoverageCreateEdges(t *testing.T) {
t.Run("temporary file disappears before stat", func(t *testing.T) {
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
installMarkdownCITempFileDisappearance(t)
installMarkdownStatUploadRemover(t)
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
"markdown", "create", "--name", "a.md", "--content", "body")
if err == nil || !strings.Contains(err.Error(), "读取上传文件失败") {
@@ -275,7 +237,7 @@ func TestMarkdownCICoverageOverwriteEdges(t *testing.T) {
t.Run("temporary file disappears before stat", func(t *testing.T) {
installMarkdownDriveDeps(t, &markdownDriveCaller{format: "json"})
installMarkdownCITempFileDisappearance(t)
installMarkdownStatUploadRemover(t)
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
"markdown", "overwrite", "--node", "node-1", "--space-id", "space-1",
"--name", "a.md", "--content", "body")
@@ -452,7 +414,7 @@ func TestMarkdownCICoveragePatchEdges(t *testing.T) {
}
installMarkdownDriveDeps(t, caller)
installMarkdownHTTPGet(t, "old")
installMarkdownCITempFileDisappearance(t)
installMarkdownStatUploadRemover(t)
err := executeMarkdownDriveCommand(t, newMarkdownCommand(), nil,
"markdown", "patch", "--node", "node-1", "--space-id", "space-1",
"--pattern", "old", "--content", "new", "--yes")
@@ -22,11 +22,9 @@ type scriptedToolCaller struct {
index int
format string
dry bool
calls int
}
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.calls++
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
-1
View File
@@ -34,7 +34,6 @@ func init() {
{"ding", newDingCommand},
{"doc", newDocCommand},
{"drive", newDriveCommand},
{"hrbrain", newHrbrainCommand},
{"live", newLiveCommand},
{"mail", newMailCommand},
{"markdown", newMarkdownCommand},
+1 -15
View File
@@ -455,12 +455,6 @@ func searchClassProject(data map[string]any) []map[string]any {
if !ok {
continue
}
// get_class_list wraps each shift's identity/label under shiftVO
// (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every
// row projects empty and the whole command silently returns nothing.
if vo, ok := m["shiftVO"].(map[string]any); ok {
m = vo
}
row := map[string]any{}
if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok {
row["classId"] = v
@@ -674,21 +668,13 @@ var SearchAdjustmentRule = shortcut.Shortcut{
// across candidate keys, so an unknown/empty shape yields an empty list rather
// than a crash or fabricated data.
func searchRuleProject(data map[string]any) []map[string]any {
// get_overtime_rule nests the list under result.atRuleList and
// get_adjustment_rule under result.adjustmentList; both wrap each rule's
// identity/label under entityVO. Probe those container keys AND unwrap
// entityVO, otherwise +search-overtime-rule / +search-adjustment-rule
// silently return empty despite the backend returning rules.
raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items")
raw := attendanceResolveList(data, "ruleList", "rules", "list", "items")
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
if vo, ok := m["entityVO"].(map[string]any); ok {
m = vo
}
row := map[string]any{}
if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok {
row["ruleId"] = v
@@ -1,70 +0,0 @@
// 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 attendance
import (
"encoding/json"
"testing"
)
// TestSearchClassProjectShiftVOShape guards against projection-data-loss:
// get_class_list nests the list under result.items and wraps each shift's
// identity under shiftVO. The projection must unwrap shiftVO or +search-class
// silently returns empty despite the backend returning shifts.
func TestSearchClassProjectShiftVOShape(t *testing.T) {
const raw = `{"result":{"items":[
{"shiftVO":{"id":957395083,"name":"default shift"}},
{"shiftVO":{"id":957395084,"name":"morning shift"}}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := searchClassProject(data)
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got)
}
if got[0]["name"] == nil || got[0]["classId"] == nil {
t.Fatalf("shiftVO fields not unwrapped: %v", got[0])
}
}
// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList)
// and get_adjustment_rule (result.adjustmentList), both wrapping the rule under
// entityVO. The resolver must probe those keys and unwrap entityVO or
// +search-overtime-rule / +search-adjustment-rule silently return empty.
func TestSearchRuleProjectEntityVOShape(t *testing.T) {
for _, tc := range []struct {
name, key string
}{
{"overtime", "atRuleList"},
{"adjustment", "adjustmentList"},
} {
raw := `{"result":{"` + tc.key + `":[
{"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}},
{"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err)
}
got := searchRuleProject(data)
if len(got) != 2 {
t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got))
}
if got[0]["ruleId"] == nil || got[0]["name"] == nil {
t.Fatalf("[%s] entityVO fields not unwrapped: %v", tc.name, got[0])
}
}
}
+1 -4
View File
@@ -494,10 +494,7 @@ func roomGroupsProject(data map[string]any) []map[string]any {
// roomGroupsContainer locates the group slice across candidate wrapper keys,
// unwrapping one nested object layer (e.g. result.list) when needed.
func roomGroupsContainer(data map[string]any) []any {
// list_meeting_room_groups nests the groups under result.groupList;
// "groupList" MUST be probed or +room-groups silently returns empty despite
// the backend returning meeting-room groups.
keys := []string{"result", "data", "list", "items", "groupList", "groups"}
keys := []string{"result", "data", "list", "items", "groups"}
for _, k := range keys {
v, ok := data[k]
if !ok {
@@ -1,37 +0,0 @@
// 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 calendar
import (
"encoding/json"
"testing"
)
// TestRoomGroupsProjectGroupListShape guards against projection-data-loss:
// list_meeting_room_groups nests the groups under result.groupList; the resolver
// must probe "groupList" or +room-groups silently returns empty despite the
// backend returning meeting-room groups.
func TestRoomGroupsProjectGroupListShape(t *testing.T) {
const raw = `{"result":{"groupList":[
{"groupId":"g1","groupName":"north rooms"},
{"groupId":"g2","groupName":"south rooms"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := roomGroupsProject(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.groupList has 2 entries, projection returned %d", len(got))
}
}
+2 -7
View File
@@ -413,12 +413,7 @@ func chatGroupResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// "roles" is probed too: chatRoleListProject reuses this resolver and
// list_custom_group_roles nests its list under result.roles — without it
// +chat-role-list silently returns empty despite the group having roles.
// Group listings key on groups/conversations, which are probed first, so
// adding roles is harmless to them.
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations", "roles"} {
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} {
v, ok := data[key]
if !ok {
continue
@@ -427,7 +422,7 @@ func chatGroupResolveList(data map[string]any) []any {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "groups", "conversations", "roles", "result", "data"} {
for _, ik := range []string{"list", "items", "groups", "conversations", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
@@ -1,54 +0,0 @@
// 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 chat
import (
"encoding/json"
"testing"
)
// TestChatRoleListProjectRolesShape guards against projection-data-loss:
// list_custom_group_roles nests the roles under result.roles; the shared
// resolver must probe "roles" or +chat-role-list silently returns empty despite
// the group having custom roles.
func TestChatRoleListProjectRolesShape(t *testing.T) {
const raw = `{"result":{"roles":[
{"openRoleId":"r1","name":"CTO"},
{"openRoleId":"r2","name":"CEO"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := chatRoleListProject(data)
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.roles has 2 entries, projection returned %d (%v)", len(got), got)
}
if got[0]["openRoleId"] == nil || got[0]["name"] == nil {
t.Fatalf("role fields missing: %v", got[0])
}
}
// TestChatGroupResolveListStillHandlesGroups ensures adding "roles" did not
// disturb the group-listing shape the resolver is shared with.
func TestChatGroupResolveListStillHandlesGroups(t *testing.T) {
const raw = `{"result":{"groups":[{"openConversationId":"c1","title":"project group"}]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := chatGroupResolveList(data); len(got) != 1 {
t.Fatalf("group shape regressed: want 1, got %d", len(got))
}
}
+4 -51
View File
@@ -214,13 +214,13 @@ func listRolesResolveList(data map[string]any) []any {
continue
}
if arr, ok := v.([]any); ok {
return listRolesFlattenGroups(arr)
return arr
}
// container may itself wrap the list one level deeper
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "labels", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return listRolesFlattenGroups(arr)
return arr
}
}
}
@@ -228,42 +228,6 @@ func listRolesResolveList(data map[string]any) []any {
return []any{}
}
// listRolesFlattenGroups flattens the grouped get_org_labels response into a
// flat list of label objects. get_org_labels returns result[] as label GROUPS
// ({groupName, labels[]}), nesting the actual roles one level under each group's
// labels[]. A naive top-level scan would hand back the group wrappers — which
// carry no labelId/name — so every row would silently project to empty. When
// the array is already a flat label list (no per-group labels[]), it is returned
// unchanged so the projection tolerates both response shapes.
func listRolesFlattenGroups(arr []any) []any {
flattened := make([]any, 0, len(arr))
sawGroup := false
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
flattened = append(flattened, item)
continue
}
var nested []any
for _, gk := range []string{"labels", "subLabels"} {
if inner, ok := m[gk].([]any); ok {
nested = inner
break
}
}
if nested != nil {
sawGroup = true
flattened = append(flattened, nested...)
continue
}
flattened = append(flattened, item)
}
if sawGroup {
return flattened
}
return arr
}
// listRolesFirst returns the first present candidate key's value.
func listRolesFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
@@ -327,12 +291,6 @@ func memberListProject(data map[string]any) []map[string]any {
if !ok {
continue
}
// get_dept_members_by_deptId wraps each member under userInfo
// (deptUserList item = {"userInfo": {userId, name, ...}}); unwrap it or
// every row projects empty and +list-dept-members silently returns none.
if ui, ok := m["userInfo"].(map[string]any); ok {
m = ui
}
row := map[string]any{}
if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil {
row["userId"] = v
@@ -348,14 +306,9 @@ func memberListProject(data map[string]any) []map[string]any {
}
// memberListFindList returns the first slice found under the common list
// container keys, or nil when none is present. get_dept_members_by_deptId nests
// its members under deptUserList, so that key must be probed too.
// container keys, or nil when none is present.
func memberListFindList(m map[string]any) []any {
// get_dept_members_by_deptId nests under deptUserList and
// get_label_members_by_labelId under labelUserList; both must be probed (and
// each item unwrapped from userInfo above) or +list-dept-members /
// +list-role-members silently return empty despite having members.
for _, k := range []string{"result", "data", "list", "items", "members", "userList", "deptUserList", "labelUserList"} {
for _, k := range []string{"result", "data", "list", "items", "members", "userList"} {
if arr, ok := m[k].([]any); ok {
return arr
}
@@ -1,84 +0,0 @@
// 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 contact
import (
"encoding/json"
"testing"
)
// TestListRolesProjectGroupedShape guards against the projection-data-loss class:
// get_org_labels returns result[] as label GROUPS with the real roles nested one
// level under each group's labels[]. The projection MUST descend into those
// nested labels[] rather than treating group wrappers as roles — otherwise it
// silently returns empty (exit 0, no error envelope) while the backend has data.
func TestListRolesProjectGroupedShape(t *testing.T) {
// Faithful get_org_labels shape (as returned by the backend / leaf command).
const raw = `{"result":[
{"groupName":"default","labels":[
{"labelId":101,"name":"roleA"},
{"labelId":102,"name":"roleB"},
{"labelId":103,"name":"roleC"}]},
{"groupName":"job","labels":[
{"labelId":104,"name":"roleD"},
{"labelId":105,"name":"roleE"}]}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
roles := listRolesProject(data)
if len(roles) != 5 {
t.Fatalf("lower/upper mismatch: 5 roles in backend, projection returned %d (roles=%v)", len(roles), roles)
}
for _, r := range roles {
if r["labelId"] == nil || r["labelName"] == nil {
t.Fatalf("projected role missing labelId/labelName: %v", r)
}
}
}
// TestListRolesProjectNestedWrapperAndNonMap covers the nested map-wrapper branch
// ({result:{labels:[...]}}) and a non-map array element, which must be passed
// through rather than dropped.
func TestListRolesProjectNestedWrapperAndNonMap(t *testing.T) {
const raw = `{"result":{"labels":[
{"groupName":"default","labels":[{"labelId":1,"name":"roleA"}]},
"junk"
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if roles := listRolesProject(data); len(roles) != 1 {
t.Fatalf("nested wrapper + non-map: want 1 role, got %d (%v)", len(roles), roles)
}
}
// TestListRolesProjectFlatShape ensures the flatten step is a no-op when the
// response is already a flat label list (response-shape drift tolerance).
func TestListRolesProjectFlatShape(t *testing.T) {
const raw = `{"result":[
{"labelId":1,"name":"sales"},
{"labelId":2,"name":"market"}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if roles := listRolesProject(data); len(roles) != 2 {
t.Fatalf("flat shape: want 2 roles, got %d (%v)", len(roles), roles)
}
}
@@ -1,76 +0,0 @@
// 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 contact
import (
"encoding/json"
"testing"
)
// TestMemberListProjectDeptUserListShape guards against projection-data-loss:
// get_dept_members_by_deptId returns members under deptUserList, each wrapped in
// userInfo. The resolver must probe deptUserList AND unwrap userInfo or
// +list-dept-members silently returns empty despite the backend returning members.
func TestMemberListProjectDeptUserListShape(t *testing.T) {
const raw = `{"deptUserList":[
{"userInfo":{"userId":"u1","name":"Alice"}},
{"userInfo":{"userId":"u2","name":"Bob"}}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := memberListProject(data)
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: deptUserList has 2 members, projection returned %d (%v)", len(got), got)
}
if got[0]["userId"] == nil || got[0]["name"] == nil {
t.Fatalf("userInfo fields not unwrapped: %v", got[0])
}
}
// TestMemberListProjectLabelUserListShape guards the role-member path:
// get_label_members_by_labelId nests members under labelUserList, each wrapped
// in userInfo. The resolver must probe labelUserList AND unwrap userInfo or
// +list-role-members silently returns empty despite the role having members.
func TestMemberListProjectLabelUserListShape(t *testing.T) {
const raw = `{"labelUserList":[
{"userInfo":{"userId":"u1","name":"Carol"}},
{"userInfo":{"userId":"u2","name":"Dave"}}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := memberListProject(data)
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: labelUserList has 2 members, projection returned %d (%v)", len(got), got)
}
if got[0]["userId"] == nil || got[0]["name"] == nil {
t.Fatalf("userInfo not unwrapped: %v", got[0])
}
}
// TestMemberListProjectFlatRoleShape ensures the shared projection still handles
// a flat member shape unchanged.
func TestMemberListProjectFlatRoleShape(t *testing.T) {
const raw = `{"result":[{"userId":"u9","name":"Alice"}]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := memberListProject(data); len(got) != 1 {
t.Fatalf("flat role-member shape: want 1, got %d (%v)", len(got), got)
}
}
+3 -27
View File
@@ -495,16 +495,8 @@ var Recent = shortcut.Shortcut{
// document list, dropping transport noise (logId) while keeping the pagination
// cursor.
func recentListProject(data map[string]any) map[string]any {
// get_recent_list nests its payload under result.recentItems; earlier this
// read data["recentItems"] at the top level only, so the whole list silently
// projected to empty. Resolve the payload container (top-level or one level
// under result/data) before reading recentItems / pagination fields.
payload := data
if inner, ok := recentListPayload(data); ok {
payload = inner
}
items := []map[string]any{}
raw, _ := payload["recentItems"].([]any)
raw, _ := data["recentItems"].([]any)
for _, it := range raw {
m, ok := it.(map[string]any)
if !ok {
@@ -520,31 +512,15 @@ func recentListProject(data map[string]any) map[string]any {
})
}
out := map[string]any{"count": len(items), "items": items}
if nc, ok := payload["nextCursor"]; ok && nc != nil {
if nc, ok := data["nextCursor"]; ok && nc != nil {
out["nextCursor"] = nc
}
if hm, ok := payload["hasMore"]; ok {
if hm, ok := data["hasMore"]; ok {
out["hasMore"] = hm
}
return out
}
// recentListPayload returns the map that actually holds recentItems, tolerating
// a {result|data:{recentItems:[...]}} envelope as well as a bare top-level shape.
func recentListPayload(data map[string]any) (map[string]any, bool) {
if _, ok := data["recentItems"]; ok {
return data, true
}
for _, k := range []string{"result", "data"} {
if inner, ok := data[k].(map[string]any); ok {
if _, ok := inner["recentItems"]; ok {
return inner, true
}
}
}
return nil, false
}
func init() {
shortcut.Register(
List,
@@ -1,66 +0,0 @@
// 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 drive
import (
"encoding/json"
"testing"
)
// TestRecentListProjectResultWrapper guards against projection-data-loss:
// get_recent_list nests its payload under result.recentItems; the projection
// must descend into result or +recent silently returns empty despite backend
// records.
func TestRecentListProjectResultWrapper(t *testing.T) {
const raw = `{"result":{"hasMore":true,"nextCursor":"c2","recentItems":[
{"name":"weekly report","nodeType":"doc","nodeId":"n1","docUrl":"https://x/1"},
{"name":"budget sheet","nodeType":"sheet","nodeId":"n2","docUrl":"https://x/2"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
out := recentListProject(data)
if got, _ := out["count"].(int); got != 2 {
t.Fatalf("lower/upper mismatch: result.recentItems has 2 entries, projection count=%v (%v)", out["count"], out)
}
if out["hasMore"] != true || out["nextCursor"] != "c2" {
t.Fatalf("pagination fields lost from result wrapper: %v", out)
}
}
// TestRecentListProjectNoItems covers the no-recentItems branch (payload not
// found under any wrapper), which must yield an empty list, not a panic.
func TestRecentListProjectNoItems(t *testing.T) {
const raw = `{"result":{"totalCount":0},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 0 {
t.Fatalf("no recentItems: want count 0, got %v", out["count"])
}
}
// TestRecentListProjectTopLevel covers the already-unwrapped shape.
func TestRecentListProjectTopLevel(t *testing.T) {
const raw = `{"recentItems":[{"name":"weekly report","nodeId":"n1"}],"hasMore":false}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if out := recentListProject(data); out["count"].(int) != 1 {
t.Fatalf("top-level recentItems: want count 1, got %v", out["count"])
}
}
@@ -1,42 +0,0 @@
// 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 minutes
import (
"encoding/json"
"testing"
)
// TestCallListProjectItemListShape guards against projection-data-loss end to
// end: list_by_keyword_and_time_range nests the minutes under result.itemList,
// so the resolver must probe "itemList", and each projected row must carry the
// real taskUuid that record-control commands consume. minutesId is deliberately
// not treated as a taskUuid (see callListProject).
func TestCallListProjectItemListShape(t *testing.T) {
const raw = `{"result":{"itemList":[
{"taskUuid":"uuid-1","title":"weekly sync"},
{"task_uuid":"uuid-2","title":"design review"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
got := callListProject(data)
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: itemList has 2 entries, projection returned %d", len(got))
}
if got[0]["taskUuid"] != "uuid-1" || got[1]["taskUuid"] != "uuid-2" {
t.Fatalf("taskUuid not projected from taskUuid/task_uuid keys: %v", got)
}
}
+3 -12
View File
@@ -113,13 +113,7 @@ func callListProject(data map[string]any) []map[string]any {
continue
}
row := map[string]any{}
// Only real taskUuid spellings map to taskUuid: the downstream
// +record-pause/resume/stop commands feed this value straight into the
// recording-control tool as the task uuid, so a minutesId (the minutes
// document id, a different identifier) must not be substituted here or
// record control would fail with a wrong id. The backend list already
// returns taskUuid.
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid"); ok {
if v, ok := callListFirst(m, "taskUuid", "task_uuid", "uuid", "id"); ok {
row["taskUuid"] = v
}
if v, ok := callListFirst(m, "title", "name"); ok {
@@ -150,10 +144,7 @@ func callListProject(data map[string]any) []map[string]any {
// callListResolveList locates the list payload inside the response, tolerating a
// bare top-level array or nesting under result/data/list/items/records containers.
func callListResolveList(data map[string]any) []any {
// list_by_keyword_and_time_range nests the minutes under result.itemList;
// "itemList" MUST be probed or +list-all/+list-mine/+list-shared silently
// return empty despite the backend returning minutes.
for _, key := range []string{"result", "data", "list", "items", "itemList", "records", "dataList"} {
for _, key := range []string{"result", "data", "list", "items", "records", "dataList"} {
v, ok := data[key]
if !ok {
continue
@@ -163,7 +154,7 @@ func callListResolveList(data map[string]any) []any {
}
// container may itself wrap the list one level deeper
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "itemList", "records", "dataList", "result", "data"} {
for _, ik := range []string{"list", "items", "records", "dataList", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
@@ -1,82 +0,0 @@
// 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 oa
import (
"encoding/json"
"testing"
)
// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss
// class: list_user_visible_process nests the forms under result.processCodeList.
// The resolver MUST probe that exact key — otherwise the whole list silently
// projects to empty (exit 0, no error envelope) while the backend has data.
func TestListFormsProjectProcessCodeListShape(t *testing.T) {
// Faithful list_user_visible_process shape (as returned by the backend).
const raw = `{"result":{"processCodeList":[
{"processCode":"PROC-1","processName":"leave","dirName":"attendance"},
{"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"},
{"processCode":"PROC-3","processName":"overtime","dirName":"attendance"}
],"totalCount":-1},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
forms := listFormsProject(data)
if len(forms) != 3 {
t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms)
}
for _, f := range forms {
if f["processCode"] == nil || f["name"] == nil {
t.Fatalf("projected form missing processCode/name: %v", f)
}
}
}
// TestListFormsProjectBareArrayShape ensures a bare top-level array still works.
func TestListFormsProjectBareArrayShape(t *testing.T) {
const raw = `{"result":[
{"processCode":"PROC-9","processName":"generic approval"}
]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if forms := listFormsProject(data); len(forms) != 1 {
t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms)
}
}
// TestOAInstanceResolveListValuesShape guards the shared resolver behind
// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval
// instance tools nest the list under result.values; the resolver must probe
// "values" or all four commands silently return empty despite backend records.
func TestOAInstanceResolveListValuesShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"values":[
{"processInstanceId":"i-1","title":"user leave"},
{"processInstanceId":"i-2","title":"user reimbursement"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := oaInstanceResolveList(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got))
}
// End-to-end through a real projection that uses the shared resolver.
if instances := listSubmittedProject(data); len(instances) != 2 {
t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances)
}
}
+4 -13
View File
@@ -137,11 +137,7 @@ func oaFormResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// list_user_visible_process nests the forms under result.processCodeList;
// probing must include that exact key (both at the top level in case the
// envelope is already unwrapped, and one level deeper under result/data) or
// the whole list silently projects to empty.
for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} {
for _, key := range []string{"result", "data", "list", "items", "processList", "forms"} {
v, ok := data[key]
if !ok {
continue
@@ -150,7 +146,7 @@ func oaFormResolveList(data map[string]any) []any {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} {
for _, ik := range []string{"list", "items", "processList", "forms", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
@@ -289,12 +285,7 @@ func oaInstanceResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// The approval instance tools (list_pending_approvals, get_done_tasks,
// get_submitted_instances, get_noticed_instances) all nest the instance list
// under result.values; "values" MUST be in the probe set or every one of
// +list-pending / +list-executed / +list-submitted / +list-cc silently
// projects to empty despite the backend returning records.
for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} {
for _, key := range []string{"result", "data", "list", "items", "instances", "tasks"} {
v, ok := data[key]
if !ok {
continue
@@ -303,7 +294,7 @@ func oaInstanceResolveList(data map[string]any) []any {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} {
for _, ik := range []string{"list", "items", "instances", "tasks", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
+2 -6
View File
@@ -131,11 +131,7 @@ func reportEntryListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); "report_list" MUST be probed — the
// camelCase "reportList" alone leaves +inbox-list / +outbox-list silently
// empty despite the backend returning reports.
for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} {
for _, key := range []string{"result", "data", "list", "items", "reportList", "records"} {
v, ok := data[key]
if !ok {
continue
@@ -144,7 +140,7 @@ func reportEntryListResolveList(data map[string]any) []any {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} {
for _, ik := range []string{"list", "items", "reportList", "records", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
@@ -1,38 +0,0 @@
// 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 report
import (
"encoding/json"
"testing"
)
// TestReportEntryListResolveListSnakeShape guards against projection-data-loss:
// get_received_report_list / get_send_report_list nest the list under
// result.report_list (snake_case); the resolver must probe "report_list" or
// +inbox-list / +outbox-list silently return empty despite the backend having
// reports.
func TestReportEntryListResolveListSnakeShape(t *testing.T) {
const raw = `{"result":{"report_list":[
{"reportId":"r1","templateName":"daily report"},
{"reportId":"r2","templateName":"weekly report"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := reportEntryListResolveList(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got))
}
}
+7 -6
View File
@@ -55,12 +55,12 @@ var CreatedTodos = shortcut.Shortcut{
`dws todo +created-todos`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my todos as creator via the shared pager. This keeps
// pageSize at todoPageSize(=20): the backend silently returns an empty
// page for pageSize>20, so the previous hard-coded pageSize=50 made this
// command always return empty even when the creator had todos. The pager
// also merges every page instead of only the first.
cards, err := shortcutListAllTodoCards(rt, map[string]any{
// Step 1 — list my todos as creator. pageNum/pageSize are strings and
// roleTypes=["creator"] mirrors helpers.buildListTodoTaskArgs, where
// "creator" is an accepted role-type enum value.
data, err := rt.CallMCPData("todo", "get_user_todos_in_current_org", map[string]any{
"pageNum": "1",
"pageSize": "50",
"roleTypes": []string{"creator"},
})
if err != nil {
@@ -69,6 +69,7 @@ var CreatedTodos = shortcut.Shortcut{
// Step 2 — project each card to {subject, taskId, dueTime}, parsing
// fields defensively (helpers reused from todo_done.go / overdue.go).
cards := shortcutTodoCards(data)
created := make([]map[string]any, 0, len(cards))
for _, m := range cards {
subject, _ := m["subject"].(string)
@@ -1,203 +0,0 @@
// 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 smart
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
var errStubTool = errors.New("stub tool error")
// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with
// an email field and bare string arrays, each at the top level or one level
// under a result/data wrapper. The nested bare-string case regressed silently
// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly
// demand an explicit --email.
func TestSearchMailPickMailbox(t *testing.T) {
cases := []struct {
name, raw, want string
}{
{"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"},
{"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"},
{"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"},
{"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"},
{"empty", `{"result":{"emailAccounts":[]}}`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var data map[string]any
if err := json.Unmarshal([]byte(tc.raw), &data); err != nil {
t.Fatal(err)
}
if got := searchMailPickMailbox(data); got != tc.want {
t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces
// container is probed — otherwise +resolve-space silently finds no candidates.
func TestResolveSpaceItemsWikiSpaces(t *testing.T) {
const raw = `{"result":{"wikiSpaces":[
{"spaceId":"s1","name":"R&D wiki"},
{"spaceId":"s2","name":"product wiki"}
]}}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatal(err)
}
if got := resolveSpaceItems(data); len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, resolver returned %d", len(got))
}
}
// --- fake MCP caller for end-to-end shortcut projection tests ---
type stubMailboxCaller struct {
byTool map[string]string
errTool string // tool name that should return a transport error
}
func (f *stubMailboxCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
if tool == f.errTool {
return nil, errStubTool
}
text := f.byTool[tool]
if text == "" {
text = `{"result":{}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (f *stubMailboxCaller) Format() string { return "json" }
func (f *stubMailboxCaller) DryRun() bool { return false }
func (f *stubMailboxCaller) Fields() string { return "" }
func (f *stubMailboxCaller) JQ() string { return "" }
func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
t.Helper()
helpers.InitDeps(fake)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(io.Discard)
root.SetArgs(argv)
if err := root.Execute(); err != nil {
t.Fatalf("execute %v: %v", argv, err)
}
return buf.String()
}
func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error {
t.Helper()
helpers.InitDeps(fake)
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(argv)
return root.Execute()
}
// TestSmartSearchMailNoMailbox covers the empty-mailbox path: when
// list_user_mailboxes yields no address, +search-mail must surface a clear
// "provide --email" validation error rather than silently proceeding.
func TestSmartSearchMailNoMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"list_user_mailboxes": `{"result":{"emailAccounts":[]}}`,
}}
err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json")
if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") {
t.Fatalf("want no-mailbox validation error, got %v", err)
}
}
// TestSmartSearchMailBackendError covers the transport-error branch of the
// mailbox resolver.
func TestSmartSearchMailBackendError(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "list_user_mailboxes"}
if err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json"); err == nil {
t.Fatal("want backend error propagated, got nil")
}
}
// TestCreatedTodosBackendError covers the error branch of +created-todos when the
// paged todo fetch fails.
func TestCreatedTodosBackendError(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{}, errTool: "get_user_todos_in_current_org"}
if err := runShortcutErr(t, fake, "todo", "+created-todos", "--format", "json"); err == nil {
t.Fatal("want backend error propagated, got nil")
}
}
// TestCreatedTodosProjectsCards runs +created-todos end to end through a fake MCP
// caller. get_user_todos_in_current_org nests cards under result.todoCards; the
// shared pager (pageSize=20, not the old 50 that silently returned empty) must
// surface them into the projected "created" list.
func TestCreatedTodosProjectsCards(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"get_user_todos_in_current_org": `{"result":{"todoCards":[
{"subject":"todoA","taskId":"111","dueTime":1784686500000},
{"subject":"todoB","taskId":"222"}
]}}`,
}}
out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json")
var d map[string]any
if err := json.Unmarshal([]byte(out), &d); err != nil {
t.Fatalf("output not json: %q", out)
}
created, _ := d["created"].([]any)
if len(created) != 2 {
t.Fatalf("lower/upper mismatch: 2 todos in backend, projection created=%d (%s)", len(created), out)
}
if !strings.Contains(out, "111") || !strings.Contains(out, "222") {
t.Fatalf("projected rows missing taskId: %s", out)
}
}
// TestSmartSearchMailAutoResolvesMailbox runs +search-mail without --email so the
// shortcut must auto-resolve the mailbox from list_user_mailboxes
// (result.emailAccounts) before searching. Before the fix this returned
// the no-mailbox validation error because emailAccounts was not probed.
func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) {
fake := &stubMailboxCaller{byTool: map[string]string{
"list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`,
"search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`,
}}
out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json")
if strings.Contains(out, "未找到可用邮箱") || out == "" {
t.Fatalf("mailbox auto-resolve failed: %s", out)
}
if !strings.Contains(out, "weekly report") {
t.Fatalf("search result not projected: %s", out)
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, key := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "records"} {
for _, key := range []string{"result", "data", "list", "items", "spaces", "records"} {
raw, ok := data[key]
if !ok {
continue
+10 -36
View File
@@ -112,49 +112,23 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) {
if err != nil {
return "", err
}
if addr := searchMailPickMailbox(data); addr != "" {
return addr, nil
}
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
}
// searchMailPickMailbox resolves the first usable mailbox address from a
// list_user_mailboxes response. It tolerates every observed shape: objects with
// an email field (result.emailAccounts[].email) and bare string arrays
// (result.emailAccounts[]), each at the top level or one level under a
// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string
// case is scanned separately — otherwise all three smart mail shortcuts wrongly
// report that no mailbox exists.
func searchMailPickMailbox(data map[string]any) string {
containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"}
for _, m := range searchMailUnwrapList(data, containers...) {
boxes := searchMailUnwrapList(data, "mailboxes", "list", "items", "data", "result", "records")
for _, m := range boxes {
if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" {
return addr
return addr, nil
}
}
scanStrings := func(m map[string]any) string {
for _, key := range containers {
if arr, ok := m[key].([]any); ok {
for _, it := range arr {
if s, ok := it.(string); ok && s != "" {
return s
}
// Some gateways return the addresses as a bare string list.
for _, key := range []string{"mailboxes", "list", "items", "data", "result"} {
if arr, ok := data[key].([]any); ok {
for _, it := range arr {
if s, ok := it.(string); ok && s != "" {
return s, nil
}
}
}
return ""
}
if s := scanStrings(data); s != "" {
return s
}
for _, wrap := range []string{"result", "data"} {
if inner, ok := data[wrap].(map[string]any); ok {
if s := scanStrings(inner); s != "" {
return s
}
}
}
return ""
return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址")
}
// searchMailMessages extracts the message list from a search_emails response.
@@ -1,49 +0,0 @@
// 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 wiki
import (
"encoding/json"
"testing"
)
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
// return empty despite the backend returning spaces.
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
{"workspaceId":"w1","name":"R&D wiki"},
{"workspaceId":"w2","name":"product wiki"}
]},"success":true}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if spaces := spaceListProject(data); len(spaces) != 2 {
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces)
}
}
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}`
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if spaces := spaceListProject(data); len(spaces) != 1 {
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
}
}
+2 -5
View File
@@ -94,15 +94,12 @@ func spaceListProject(data map[string]any) []map[string]any {
// wikiSpaceRawList locates the space array across candidate container keys,
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
func wikiSpaceRawList(data map[string]any) []any {
// list_wikiSpaces / search_wikiSpaces nest the space list under
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
// MUST be probed or +space-list / +space-search silently return empty.
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
for _, k := range []string{"result", "data", "list", "items", "spaces", "workspaces"} {
if arr, ok := data[k].([]any); ok {
return arr
}
if inner, ok := data[k].(map[string]any); ok {
for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
for _, ik := range []string{"list", "items", "spaces", "workspaces", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
-1
View File
@@ -20,7 +20,6 @@ func StaticServers() []ServerInfo {
{ID: "doc", Name: "文档", Endpoint: "https://mcp-gw.dingtalk.com/server/91e17caf44f6ca1ed9c6ce614221a518ac93300ece63ca8d7e9b133f912e0607", Prefixes: []string{"doc"}},
{ID: "doc-comment", Name: "文档评论", Endpoint: "https://mcp-gw.dingtalk.com/server/doc-comment", Prefixes: []string{"doc-comment"}},
{ID: "drive", Name: "钉盘", Endpoint: "https://mcp-gw.dingtalk.com/server/536f3b329ee774322b14361c666d6e9471e5bbb281b91ded8ca033b3ce7189af", Prefixes: []string{"drive"}},
{ID: "hrbrain", Name: "组织大脑", Endpoint: "https://mcp-gw.dingtalk.com/server/377efb1093f09206cea6ccd030880d274668fb2e7a4b1bb7b96996a4fe7bb619", Prefixes: []string{"hrbrain"}},
{ID: "hrmregister", Name: "智能人事", Endpoint: "https://mcp-gw.dingtalk.com/server/2494acfc37317871865c9c715664792200a52a89aeab58fee61cfc7262463c39", Prefixes: []string{"hrmregister"}},
{ID: "im", Name: "im", Endpoint: "https://mcp-gw.dingtalk.com/server/450eede6b54d83e030140e66ec77c98a2e89a0869ef4db481f8217a98a42f821", Prefixes: []string{"im"}},
{ID: "live", Name: "直播", Endpoint: "https://mcp-gw.dingtalk.com/server/57fb66889659c56df8f0a1838026b9004d8f4257fbef54fd04a028426253a033", Prefixes: []string{"live"}},
-1
View File
@@ -16,7 +16,6 @@ func CmdToProduct() map[string]string {
"ding": "ding",
"doc": "doc",
"drive": "drive",
"hrbrain": "hrbrain",
"live": "live",
"mail": "mail",
"minutes": "minutes",
-136
View File
@@ -1,136 +0,0 @@
#!/bin/sh
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0 (the "License");
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
usage() {
printf '%s\n' "usage: $0 <changed|list> BASE_REF HEAD_REF" >&2
exit 2
}
[ "${1:-}" = changed ] || [ "${1:-}" = list ] || usage
[ "$#" -eq 3 ] || usage
MODE="$1"
BASE_REF="$2"
HEAD_REF="$3"
cd "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'changed test package base is not a commit: %s\n' "$BASE_REF" >&2
exit 2
}
git rev-parse --verify --quiet "${HEAD_REF}^{commit}" >/dev/null || {
printf 'changed test package head is not a commit: %s\n' "$HEAD_REF" >&2
exit 2
}
resolved_head="$(git rev-parse "${HEAD_REF}^{commit}")"
current_head="$(git rev-parse HEAD)"
[ "$current_head" = "$resolved_head" ] || {
printf 'changed test package head does not match the checked-out revision: expected %s, got %s\n' \
"$resolved_head" "$current_head" >&2
exit 2
}
git diff --quiet HEAD -- && git diff --cached --quiet HEAD -- || {
printf '%s\n' 'changed test package planning requires a clean tracked worktree' >&2
exit 2
}
workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-changed-packages.XXXXXX")"
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
files="$workdir/files"
changed_packages="$workdir/changed-packages"
impacted_packages="$workdir/impacted-packages"
all_packages="$workdir/all-packages"
package_dependencies="$workdir/package-dependencies"
embed_owners="$workdir/embed-owners"
: > "$changed_packages"
: > "$impacted_packages"
git diff --no-ext-diff --find-renames --name-only \
--diff-filter=ACMRD "$BASE_REF" "$HEAD_REF" > "$files"
if ! go list ./... > "$all_packages"; then
printf '%s\n' 'failed to resolve the module package graph' >&2
exit 1
fi
if ! go list -f \
'{{- $import := .ImportPath -}}{{- $dir := .Dir -}}{{- range .EmbedFiles }}{{$import}}|{{$dir}}|{{.}}{{"\n"}}{{- end -}}{{- range .TestEmbedFiles }}{{$import}}|{{$dir}}|{{.}}{{"\n"}}{{- end -}}{{- range .XTestEmbedFiles }}{{$import}}|{{$dir}}|{{.}}{{"\n"}}{{- end -}}' \
./... > "$embed_owners"; then
printf '%s\n' 'failed to resolve embedded file ownership' >&2
exit 1
fi
while IFS= read -r file; do
[ -n "$file" ] || continue
case "$file" in
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|\
CODE_OF_CONDUCT.md|LICENSE|NOTICE|docs/*|\
.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*)
continue
;;
esac
directory="$(dirname "$file")"
if [ "$directory" = . ]; then
pattern=.
else
pattern="./$directory"
fi
if [ -d "$directory" ]; then
has_go_files=false
for go_file in "$directory"/*.go; do
if [ -f "$go_file" ]; then
has_go_files=true
break
fi
done
if [ "$has_go_files" = true ]; then
if ! package="$(go list -f '{{.ImportPath}}' "$pattern")"; then
printf 'failed to resolve changed Go package: %s\n' "$directory" >&2
exit 1
fi
printf '%s\n' "$package" >> "$changed_packages"
continue
fi
fi
embedded_owner_found=false
while IFS='|' read -r package package_dir embedded_file; do
[ -n "$package" ] || continue
case "$package_dir" in
"$ROOT") embedded_path="$embedded_file" ;;
"$ROOT"/*)
embedded_path="${package_dir#"$ROOT"/}/$embedded_file"
;;
*) continue ;;
esac
if [ "$embedded_path" = "$file" ]; then
printf '%s\n' "$package" >> "$changed_packages"
embedded_owner_found=true
fi
done < "$embed_owners"
[ "$embedded_owner_found" = true ] || continue
done < "$files"
LC_ALL=C sort -u "$changed_packages" -o "$changed_packages"
if [ "$MODE" = changed ] || [ ! -s "$changed_packages" ]; then
cat "$changed_packages"
exit 0
fi
while IFS= read -r package; do
if ! go list -deps -test "$package" > "$package_dependencies"; then
printf 'failed to resolve dependencies for Go package: %s\n' "$package" >&2
exit 1
fi
if grep -Fxf "$changed_packages" "$package_dependencies" >/dev/null; then
printf '%s\n' "$package"
fi
done < "$all_packages" > "$impacted_packages"
cat "$changed_packages" >> "$impacted_packages"
LC_ALL=C sort -u "$impacted_packages"
+4 -35
View File
@@ -6,25 +6,16 @@ from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import shlex
import subprocess
import time
from pathlib import Path
from shortcut_real_result import (
classify_failure,
summarize_failure_categories,
classify_real_status,
summarize_results,
projection_audit,
)
from shortcut_real_result import classify_failure, summarize_failure_categories, classify_real_status, summarize_results
ROOT = Path(__file__).resolve().parents[1]
OUT_PATH = Path(
os.environ.get("SHORTCUT_REAL_RESULTS_PATH", ROOT / "docs" / "shortcut-real-write-results.json")
)
OUT_PATH = ROOT / "docs" / "shortcut-real-write-results.json"
def load_report() -> dict:
@@ -48,12 +39,6 @@ def main() -> int:
p.add_argument("--risk", default="write")
p.add_argument("--method", default="real-backend-manual-write; no --mock; no --dry-run")
p.add_argument("--timeout", type=int, default=35)
p.add_argument(
"--backend-raw",
help="Path to a JSON file holding the lower-layer MCP tool response for "
"this shortcut (captured separately). Used only in memory to compare the "
"upper projection against the lower layer; never persisted.",
)
p.add_argument("argv", nargs=argparse.REMAINDER)
ns = p.parse_args()
argv = ns.argv
@@ -62,12 +47,6 @@ def main() -> int:
if not argv:
raise SystemExit("missing command argv")
# Lower layer stays in memory only — we persist derived counts, never the raw
# PII-bearing backend payload.
backend_raw = None
if ns.backend_raw:
backend_raw = json.loads(Path(ns.backend_raw).read_text(encoding="utf-8"))
started = time.time()
status = "real-error"
stdout = ""
@@ -78,7 +57,7 @@ def main() -> int:
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
exit_code = proc.returncode
status = classify_real_status(exit_code, stdout, backend_raw=backend_raw)
status = classify_real_status(exit_code, stdout)
except subprocess.TimeoutExpired as e:
status = "timeout"
if isinstance(e.stdout, str):
@@ -104,17 +83,7 @@ def main() -> int:
"duration_ms": duration_ms,
"recorded_at": dt.datetime.now().isoformat(),
}
# Compare upper projection vs lower layer in memory; persist only the derived
# verdict (kind + counts), never the raw backend payload.
if backend_raw is not None:
audit = projection_audit({**result, "backend": backend_raw})
if audit is not None:
result["projection_audit"] = audit
# classify_failure inspects the lower layer to flag projection-data-loss, so
# hand it a copy carrying the in-memory backend; the persisted `result` keeps
# only the verdict.
category, fixability, note = classify_failure({**result, "backend": backend_raw})
category, fixability, note = classify_failure(result)
if category != "passed":
result["failure_category"] = category
result["fixability"] = fixability
+27 -91
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
FORMULA_SOURCE="${DWS_FORMULA_SOURCE:-$ROOT/dist/homebrew/dingtalk-workspace-cli.rb}"
TAP_REPO_URL="${DWS_TAP_REPO_URL:-}"
TAP_BRANCH="${DWS_TAP_BRANCH:-main}"
@@ -14,7 +14,6 @@ PR_TITLE="${DWS_TAP_PR_TITLE:-chore: update Homebrew formula}"
COMMIT_MESSAGE="${DWS_TAP_COMMIT_MESSAGE:-chore: update dingtalk-workspace-cli formula}"
GIT_NAME="${DWS_GIT_NAME:-DWS Release Bot}"
GIT_EMAIL="${DWS_GIT_EMAIL:-dws-release-bot@example.com}"
PUBLISH_OUTPUT="${DWS_PUBLISH_OUTPUT:-${GITHUB_OUTPUT:-}}"
say() {
printf '%s\n' "$*"
@@ -39,15 +38,6 @@ need_env() {
[ -n "$value" ] || err "missing required environment variable: $name"
}
record_direct_result() {
changed="$1"
commit="$2"
[ -z "$PUBLISH_OUTPUT" ] || {
printf 'formula_changed=%s\npublished_commit=%s\n' "$changed" "$commit" \
>> "$PUBLISH_OUTPUT"
}
}
checkout_tap_branch() {
repo_url="$1"
branch="$2"
@@ -97,7 +87,6 @@ if [ -n "$TAP_SSH_KEY" ]; then
fi
if [ -n "$TAP_GITHUB_TOKEN" ]; then
ASKPASS="$TMP_ROOT/git-askpass.sh"
# shellcheck disable=SC2016 # Preserve variables for the generated helper.
printf '%s\n' \
'#!/bin/sh' \
'case "$1" in' \
@@ -116,70 +105,7 @@ if [ -n "$PR_REPOSITORY" ] || [ -n "$PR_BRANCH" ]; then
need_cmd gh
fi
if [ -z "$PR_REPOSITORY" ]; then
case "$TAP_FORMULA_PATH" in
Formula/dingtalk-workspace-cli.rb|Formula/dingtalk-workspace-cli-beta.rb) ;;
*) err "direct Homebrew publication is restricted to the stable or beta DWS Formula" ;;
esac
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
TAP_DIR="$TMP_ROOT/tap-direct-$attempt"
checkout_tap_branch "$TAP_REPO_URL" "$TAP_BRANCH" "$TAP_DIR"
DEST_PATH="$TAP_DIR/$TAP_FORMULA_PATH"
mkdir -p "$(dirname "$DEST_PATH")"
cp "$FORMULA_SOURCE" "$DEST_PATH"
if (
cd "$TAP_DIR" || exit $?
formula_status="$(git status --porcelain --untracked-files=all)" || exit $?
if [ -z "$formula_status" ]; then
formula_commit="$(git log -1 --format=%H -- "$TAP_FORMULA_PATH")" || exit $?
[ -n "$formula_commit" ] ||
err "could not resolve the existing commit for $TAP_FORMULA_PATH"
record_direct_result false "$formula_commit" || exit $?
exit 20
fi
case "$formula_status" in
" M $TAP_FORMULA_PATH"|"?? $TAP_FORMULA_PATH") ;;
*) err "direct Homebrew publication may change only $TAP_FORMULA_PATH" ;;
esac
git config user.name "$GIT_NAME" || exit $?
git config user.email "$GIT_EMAIL" || exit $?
git add "$TAP_FORMULA_PATH" || exit $?
staged_paths="$(git diff --cached --name-only)" || exit $?
[ "$staged_paths" = "$TAP_FORMULA_PATH" ] ||
err "staged Homebrew publication contains files other than $TAP_FORMULA_PATH"
git commit -m "$COMMIT_MESSAGE" >/dev/null || exit $?
committed_paths="$(git diff-tree --no-commit-id --name-only -r HEAD)" || exit $?
[ "$committed_paths" = "$TAP_FORMULA_PATH" ] ||
err "Homebrew publication commit contains files other than $TAP_FORMULA_PATH"
# Never force the tap's default branch. A concurrent main update gets a
# fresh clone and a newly based Formula-only commit on the next attempt.
git push origin "HEAD:$TAP_BRANCH" >/dev/null || exit $?
published_commit="$(git rev-parse HEAD)" || exit $?
record_direct_result true "$published_commit" || exit $?
); then
say "Published Homebrew formula to $TAP_REPO_URL ($TAP_BRANCH)"
exit 0
else
status=$?
fi
if [ "$status" -eq 20 ]; then
say "No formula changes to publish."
exit 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
err "could not publish Homebrew formula after $max_attempts non-forced attempts"
fi
say "Homebrew target branch moved or rejected the update; retrying from a fresh clone."
attempt=$((attempt + 1))
done
fi
TAP_DIR="$TMP_ROOT/tap-pr"
TAP_DIR="$TMP_ROOT/tap"
checkout_tap_branch "$TAP_REPO_URL" "$TAP_BRANCH" "$TAP_DIR"
DEST_PATH="$TAP_DIR/$TAP_FORMULA_PATH"
@@ -197,24 +123,34 @@ cp "$FORMULA_SOURCE" "$DEST_PATH"
git config user.email "$GIT_EMAIL"
git add "$TAP_FORMULA_PATH"
git commit -m "$COMMIT_MESSAGE" >/dev/null
git push --force-with-lease origin "HEAD:$PR_BRANCH" >/dev/null
pr_url="$(
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr list \
--repo "$PR_REPOSITORY" \
--head "$PR_BRANCH" \
--state open \
--json url \
--jq '.[0].url'
)"
if [ -z "$pr_url" ]; then
if [ -n "$PR_REPOSITORY" ]; then
git push --force-with-lease origin "HEAD:$PR_BRANCH" >/dev/null
pr_url="$(
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr create \
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr list \
--repo "$PR_REPOSITORY" \
--base "$TAP_BRANCH" \
--head "$PR_BRANCH" \
--title "$PR_TITLE" \
--body "Automated Homebrew Formula update. Merge after required checks pass."
--state open \
--json url \
--jq '.[0].url'
)"
if [ -z "$pr_url" ]; then
pr_url="$(
GH_TOKEN="$TAP_GITHUB_TOKEN" gh pr create \
--repo "$PR_REPOSITORY" \
--base "$TAP_BRANCH" \
--head "$PR_BRANCH" \
--title "$PR_TITLE" \
--body "Automated Homebrew Formula update. Merge after required checks pass."
)"
fi
say "Opened Homebrew formula PR: $pr_url"
exit 0
fi
say "Opened Homebrew formula PR: $pr_url"
git push origin "HEAD:$TAP_BRANCH" >/dev/null
)
if [ -z "$PR_REPOSITORY" ]; then
say "Published Homebrew formula to $TAP_REPO_URL ($TAP_BRANCH)"
fi
+5 -6
View File
@@ -1,10 +1,9 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
# shellcheck disable=SC1091 # Resolved relative to this script at runtime.
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd)"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
VERSION="${1:-}"
REMOTE=""
@@ -248,7 +247,7 @@ printf ' commit: %s\n' "$commit"
printf ' failed run: https://github.com/%s/actions/runs/%s/attempts/%s\n' \
"$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT"
[ -t 0 ] || { printf '%s\n' 'interactive recovery confirmation is required' >&2; exit 1; }
printf 'Type %s to request machine-verified recovery: ' "$VERSION"
printf 'Type %s to request protected recovery: ' "$VERSION"
IFS= read -r confirmation
[ "$confirmation" = "$VERSION" ] || { printf '%s\n' 'release recovery cancelled' >&2; exit 1; }
@@ -285,8 +284,8 @@ while :; do
sleep 2
done
printf 'Machine-verified recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
printf '%s\n' 'The workflow will verify the exact tag, failed run, requester, and sealed metadata before rebuilding.'
printf 'Protected recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
printf '%s\n' 'Approve the release-recovery environment when prompted; this command will follow the run.'
if ! gh run watch "$run_id" --repo "$EXPECTED_REPOSITORY" --exit-status; then
gh run view "$run_id" --repo "$EXPECTED_REPOSITORY" --log-failed >&2 || true
exit 1
+1 -156
View File
@@ -80,120 +80,13 @@ def payload_indicates_error(stdout: str) -> bool:
return False
# ---------------------------------------------------------------------------
# 上层 / 下层数据一致性(投影保真)
#
# 警示案例:contact +list-roles、oa +list-forms 在 exit 0、无 error 信封的情况下
# 依然静默返空——底层 MCP 明明有 57 个角色 / 93 张表单,投影层因为容器 key 对不上
# (result[].labels[] 未下钻 / 缺 processCodeList)把数据全吃掉了。只看上层(便捷层
# 投影输出)永远发现不了这类问题;**shortcut 务必对比上层投影与下层原始后端数据**。
#
# 判定规则因此新增一条铁律:一个只读/列表类 shortcut,若上层投影为空,必须能拿下层
# 原始响应来佐证「本就没有数据」。拿不到下层、或下层明明有数据,都不能算 real-ok。
# ---------------------------------------------------------------------------
# 便捷层投影输出里承载业务列表的常见容器字段名。
_PROJECTION_LIST_KEYS = (
"roles", "forms", "users", "user", "items", "list", "records", "files",
"docs", "nodes", "messages", "conversations", "groups", "events",
"attendees", "rooms", "threads", "folders", "tags", "templates", "contacts",
"spaces", "tasks", "todos", "created", "members", "depts", "departments",
"sheets", "bases", "tables", "views", "apps", "minutes", "reports",
"comments", "attachments", "instances", "robots", "bots", "results",
"workflows", "permissions", "versions", "calendars", "cards",
)
def count_projection_items(stdout: str) -> int | None:
"""从便捷层投影输出里数出条目数(上层)。
返回 None 表示这段输出不像一个「列表型」投影(例如详情命令、纯文本、写操作
回执),此时不参与空投影判定。识别到列表型投影时返回其条目数(可能为 0)。
"""
best: int | None = None
for value in _json_values(stdout):
if isinstance(value, list):
best = max(best or 0, len(value))
continue
if not isinstance(value, dict):
continue
count_field = value.get("count")
if isinstance(count_field, bool):
count_field = None
if isinstance(count_field, int):
best = max(best or 0, count_field)
for key in _PROJECTION_LIST_KEYS:
member = value.get(key)
if isinstance(member, list):
best = max(best or 0, len(member))
return best
def backend_record_count(raw: Any, depth: int = 0) -> int:
"""递归估算下层原始后端响应里的业务条目数(下层真值)。
取任意深度下「最大的一个对象数组」的长度作为「后端是否有数据」的代理指标——
足以判定投影是否把非空的底层数据吃成了空。
"""
if depth > 6 or raw is None:
return 0
best = 0
if isinstance(raw, list):
obj_items = sum(1 for item in raw if isinstance(item, dict))
best = max(best, obj_items)
for item in raw:
best = max(best, backend_record_count(item, depth + 1))
elif isinstance(raw, dict):
for value in raw.values():
best = max(best, backend_record_count(value, depth + 1))
return best
def _backend_payload(result: dict[str, Any]) -> Any:
"""取出随 result 一起记录的下层原始后端响应(若采集了的话)。
约定字段:backend_raw / raw_backend / backend / lower_layer / backend_stdout。
backend_stdout 是字符串时按 JSON 解析。"""
for key in ("backend_raw", "raw_backend", "backend", "lower_layer"):
if key in result and result[key] not in (None, "", {}, []):
return result[key]
text = result.get("backend_stdout")
if isinstance(text, str) and text.strip():
values = _json_values(text)
if values:
return values[0] if len(values) == 1 else values
return None
def compare_layers(shortcut_stdout: str, backend_raw: Any) -> tuple[bool, int | None, int]:
"""对比上层投影与下层原始后端数据。
返回 (是否投影吃了数据, 上层条目数, 下层条目数)。当上层是列表型投影且为空、
而下层明显有对象数组时,判定为投影数据丢失(True)。"""
upper = count_projection_items(shortcut_stdout)
lower = backend_record_count(backend_raw)
lost = upper == 0 and lower > 0
return lost, upper, lower
def classify_real_status(
exit_code: int | None,
stdout: str,
current_status: str | None = None,
*,
backend_raw: Any = None,
) -> str:
def classify_real_status(exit_code: int | None, stdout: str, current_status: str | None = None) -> str:
if current_status in {"timeout", "held"}:
return current_status
if exit_code != 0:
return "real-error"
if payload_indicates_error(stdout):
return "real-error"
# 上下层比对:下层有数据但上层投影为空 = 投影吃数据,绝不能算通过。
if backend_raw is not None:
lost, _upper, _lower = compare_layers(stdout, backend_raw)
if lost:
return "real-error"
return "real-ok"
@@ -204,22 +97,6 @@ def _haystack(result: dict[str, Any]) -> str:
def classify_failure(result: dict[str, Any]) -> tuple[str, str, str]:
"""Return (category, fixability, note) for a real-test result."""
status = result.get("status")
# 上下层比对优先:即便进程 exit 0、无 error 信封、status 被记成 real-ok,只要
# 采集到的下层原始后端有数据而上层投影为空,就是投影把数据吃了——判定为
# projection-data-loss,属于 shortcut 自身可修的 bug(改投影层的容器 key/下钻)。
backend_raw = _backend_payload(result)
if backend_raw is not None and result.get("exit_code") in (0, None):
lost, upper, lower = compare_layers(result.get("stdout") or "", backend_raw)
if lost:
return (
"projection-data-loss",
"cli-projection-fix-needed",
f"上层便捷层投影为空({upper} 条),但下层原始后端有数据(约 {lower} 条);"
"投影层的容器 key/嵌套下钻与真实响应结构不匹配,把非空数据吃成了空。"
"需修投影层解析,并补「喂真实响应结构、断言非空」的单测。",
)
if status == "real-ok":
return ("passed", "fixed", "真实后端执行成功。")
if status == "timeout":
@@ -386,38 +263,6 @@ def summarize_failure_categories(results: list[dict[str, Any]]) -> dict[str, int
return dict(sorted(out.items(), key=lambda kv: (-kv[1], kv[0])))
def projection_audit(result: dict[str, Any]) -> dict[str, Any] | None:
"""投影保真审计:对只读/列表类 shortcut 强制「上下层比对」这条铁律。
返回 None 表示无需关注;否则返回一个 warning dict 供报告展示:
- projection-data-loss:下层有数据、上层空 —— 确定的投影吃数据 bug;
- empty-projection-unverified:上层投影为空但缺下层采集 —— 无法证明「本就为空」,
必须补采下层原始响应(原始 MCP 响应 / 对应 leaf 命令)再比对,禁止直接判 real-ok。
"""
stdout = result.get("stdout") or ""
upper = count_projection_items(stdout)
if upper is None or upper > 0:
return None # 非列表型投影,或上层本就有数据 —— 最常见的正常情形。
backend_raw = _backend_payload(result)
if backend_raw is None:
return {
"kind": "empty-projection-unverified",
"upper_count": upper,
"backend_count": None,
"note": "便捷层投影为空,但未采集下层原始后端响应,无法证明底层确实无数据;"
"按铁律必须补采下层后再比对,不能直接判 real-ok。",
}
lost, _upper, lower = compare_layers(stdout, backend_raw)
if lost:
return {
"kind": "projection-data-loss",
"upper_count": upper,
"backend_count": lower,
"note": "下层原始后端有数据但上层投影为空:投影层解析与真实响应结构不匹配。",
}
return None
_OSS_URL_RE = re.compile(r"https?://[^\s\"'<>]*oss[^\s\"'<>]*", re.IGNORECASE)
_ALIBABA_ACCESS_KEY_ID_RE = re.compile(r"\bLTAI[A-Za-z0-9]{12,}\b")
_OSS_QUERY_KEY_RE = re.compile(
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""Self-checking tests for scripts/shortcut_real_result.py.
Run: python3 scripts/shortcut_real_result_test.py
Focus: the upper/lower layer comparison rules added after the
contact +list-roles / oa +list-forms projection-data-loss case, plus a
regression guard that pre-existing exit-code / error-envelope behaviour is
unchanged.
"""
from __future__ import annotations
import json
from shortcut_real_result import (
backend_record_count,
classify_failure,
classify_real_status,
compare_layers,
count_projection_items,
payload_indicates_error,
projection_audit,
)
# Faithful backend shapes from the real case.
GROUPED_ROLES = {
"result": [
{"groupName": "默认", "labels": [{"labelId": 1, "name": "主管理员"}, {"labelId": 2, "name": "负责人"}]},
{"groupName": "职务", "labels": [{"labelId": 3, "name": "财务"}]},
]
}
PROCESS_CODE_LIST = {
"result": {
"processCodeList": [
{"processCode": "PROC-1", "processName": "请假"},
{"processCode": "PROC-2", "processName": "加班"},
],
"totalCount": -1,
},
"success": True,
}
EMPTY_ROLES_PROJECTION = json.dumps({"count": 0, "roles": []})
FULL_ROLES_PROJECTION = json.dumps({"count": 2, "roles": [{"labelId": 1}, {"labelId": 2}]})
DETAIL_PROJECTION = json.dumps({"userId": "u1", "name": "张三"})
def check(name, cond):
if not cond:
raise AssertionError(f"FAIL: {name}")
print(f" ok: {name}")
def test_count_projection_items():
check("empty roles -> 0", count_projection_items(EMPTY_ROLES_PROJECTION) == 0)
check("full roles -> 2", count_projection_items(FULL_ROLES_PROJECTION) == 2)
check("detail (no list) -> None", count_projection_items(DETAIL_PROJECTION) is None)
check("bare array -> len", count_projection_items("[1,2,3]") == 3)
check("empty string -> None", count_projection_items("") is None)
def test_backend_record_count():
# backend_record_count is a "has data" proxy = size of the largest single
# object array found at any depth (not the flattened total).
check("grouped roles has data (>0)", backend_record_count(GROUPED_ROLES) == 2)
check("processCodeList count", backend_record_count(PROCESS_CODE_LIST) == 2)
check("empty backend -> 0", backend_record_count({"result": []}) == 0)
check("error envelope -> 0", backend_record_count({"errorCode": 1, "errorMessage": "x"}) == 0)
def test_compare_layers():
lost, upper, lower = compare_layers(EMPTY_ROLES_PROJECTION, GROUPED_ROLES)
check("roles: data loss detected", lost and upper == 0 and lower == 2)
lost2, _, _ = compare_layers(FULL_ROLES_PROJECTION, GROUPED_ROLES)
check("roles: no loss when upper populated", not lost2)
lost3, _, lower3 = compare_layers(EMPTY_ROLES_PROJECTION, {"result": []})
check("roles: no loss when backend also empty", not lost3 and lower3 == 0)
def test_classify_real_status():
# Backward-compat: without backend_raw, empty projection is still real-ok.
check(
"legacy empty projection stays real-ok",
classify_real_status(0, EMPTY_ROLES_PROJECTION) == "real-ok",
)
# With lower layer that has data, empty projection becomes real-error.
check(
"empty projection + backend data -> real-error",
classify_real_status(0, EMPTY_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-error",
)
# Populated projection stays ok even with backend.
check(
"full projection + backend -> real-ok",
classify_real_status(0, FULL_ROLES_PROJECTION, backend_raw=GROUPED_ROLES) == "real-ok",
)
# Regression: non-zero exit and error envelope untouched.
check("nonzero exit -> real-error", classify_real_status(5, "") == "real-error")
check(
"error envelope -> real-error",
classify_real_status(0, json.dumps({"success": False, "error": "boom"})) == "real-error",
)
check("timeout preserved", classify_real_status(0, "", "timeout") == "timeout")
def test_classify_failure_projection_loss():
result = {
"status": "real-ok", # process exited 0, looked fine
"exit_code": 0,
"service": "contact",
"command": "+list-roles",
"stdout": EMPTY_ROLES_PROJECTION,
"backend_stdout": json.dumps(GROUPED_ROLES),
}
category, fixability, _ = classify_failure(result)
check("projection-data-loss category", category == "projection-data-loss")
check("cli fixable", fixability == "cli-projection-fix-needed")
# No loss when projection is populated.
ok_result = dict(result, stdout=FULL_ROLES_PROJECTION)
check("populated -> passed", classify_failure(ok_result)[0] == "passed")
# Regression: a normal real-ok with no backend capture still passes.
check(
"plain real-ok passes",
classify_failure({"status": "real-ok", "exit_code": 0, "stdout": FULL_ROLES_PROJECTION})[0] == "passed",
)
def test_projection_audit():
# Empty projection, no backend -> must-verify warning.
warn = projection_audit({"stdout": EMPTY_ROLES_PROJECTION})
check("unverified empty flagged", warn is not None and warn["kind"] == "empty-projection-unverified")
# Empty projection + backend data -> data loss.
warn2 = projection_audit({"stdout": EMPTY_ROLES_PROJECTION, "backend": GROUPED_ROLES})
check("data loss flagged", warn2 is not None and warn2["kind"] == "projection-data-loss")
# Populated projection -> no warning.
check("populated -> no warning", projection_audit({"stdout": FULL_ROLES_PROJECTION}) is None)
# Detail (non-list) projection -> no warning.
check("detail -> no warning", projection_audit({"stdout": DETAIL_PROJECTION}) is None)
def test_payload_indicates_error_regression():
check("clean payload not error", not payload_indicates_error(FULL_ROLES_PROJECTION))
check("errorCode!=0 is error", payload_indicates_error(json.dumps({"errorCode": 1})))
check("errorCode==0 not error", not payload_indicates_error(json.dumps({"errorCode": 0})))
def test_record_run_wires_lower_layer():
"""End-to-end: record_real_shortcut_run.py must capture the lower layer and
flag an exit-0 empty projection as projection-data-loss (not real-ok)."""
import os
import subprocess
import tempfile
here = os.path.dirname(os.path.abspath(__file__))
recorder = os.path.join(here, "record_real_shortcut_run.py")
with tempfile.TemporaryDirectory() as d:
backend = os.path.join(d, "backend.json")
out = os.path.join(d, "out.json")
# Lower layer has 3 roles nested under result[].labels[].
with open(backend, "w") as f:
json.dump(GROUPED_ROLES, f)
env = dict(os.environ, SHORTCUT_REAL_RESULTS_PATH=out)
# Upper command: an empty projection (exit 0, no error envelope).
argv = ["python3", "-c", "print('{\"count\": 0, \"roles\": []}')"]
p = subprocess.run(
["python3", recorder, "--service", "contact", "--command", "+list-roles",
"--risk", "read", "--backend-raw", backend, "--"] + argv,
capture_output=True, text=True, env=env,
)
check("recorder exits non-zero on data loss", p.returncode == 2)
rec = json.loads(p.stdout)
check("status flipped to real-error", rec["status"] == "real-error")
check("category is projection-data-loss", rec.get("failure_category") == "projection-data-loss")
check("projection_audit attached", rec.get("projection_audit", {}).get("kind") == "projection-data-loss")
# Raw backend payload must NOT be persisted.
persisted = json.load(open(out))
blob = json.dumps(persisted, ensure_ascii=False)
check("raw lower-layer not persisted", "主管理员" not in blob and "labels" not in blob)
def main():
tests = [
test_count_projection_items,
test_backend_record_count,
test_compare_layers,
test_classify_real_status,
test_classify_failure_projection_loss,
test_projection_audit,
test_payload_indicates_error_regression,
test_record_run_wires_lower_layer,
]
for t in tests:
print(f"{t.__name__}:")
t()
print(f"\nAll {len(tests)} test groups passed.")
if __name__ == "__main__":
main()
+4 -7
View File
@@ -74,17 +74,16 @@ cli_version: ">=1.0.15"
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aiapp` | AI应用:创建/查询/修改AI应用 | [aiapp.md](./references/products/aiapp.md) |
| `aisearch` | AI搜问(通用找人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品;不含人才池/绩效/职业历程等专项 HR 场景(那些去 `hrbrain`) | [aisearch.md](./references/products/aisearch.md) |
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/消息收藏/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
| `contact` | 通讯录:用户查询/部门/角色/花名册(学历/家庭/银行卡/紧急联系人/合同等基础字段)/离职员工/特别关注,以及创建企业、企业账号和邀请员工;不含职业历程/绩效/人才池(那些去 `hrbrain`) | [contact.md](./references/products/contact.md) |
| `contact` | 通讯录:用户查询/部门/角色/花名册/离职员工/特别关注,以及创建企业、企业账号和邀请员工 | [contact.md](./references/products/contact.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名/**删除/导出 docx/权限管理/媒体上传下载** | [doc.md](./references/products/doc.md) |
| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) |
| `hrbrain` | 组织大脑:人才池管理/员工档案专项模块查询(元数据/批量数据/标签/职业历程/绩效)/结构化高级人才搜索(原始条件表达式);区别于 `contact` 的基础通讯录档案与 `aisearch` 的通用语义找人 | [hrbrain.md](./references/products/hrbrain.md) |
| `markdown` | 原生 Markdown 文件:读取/创建/全量覆盖/字面量或 RE2 局部替换 | [markdown.md](./references/products/markdown.md) |
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/发言人段落总结/热词/录音控制/成员权限/上传 | [minutes.md](./references/products/minutes.md) |
| `oa` | OA审批:待处理/详情/同意/拒绝/撤销/记录/已发起/任务/转交/评论/抄送 | [oa.md](./references/products/oa.md) |
@@ -100,17 +99,16 @@ cli_version: ">=1.0.15"
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`(通用语义找人;若明确涉及人才池/绩效/职业历程/结构化高级条件,去 `hrbrain`)
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
用户提到"群聊/建群/群成员/群管理/发消息/发图片消息/发文件消息/发 Markdown 消息/截图发钉钉/转发消息/引用回复/@我/特别关注消息/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/基础员工档案(学历/家庭/银行卡/紧急联系人/合同)/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人/创建企业/企业账号/邀请员工/新员工入职" → `contact`(不含职业历程/绩效/人才池;那些去 `hrbrain`)
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人/创建企业/企业账号/邀请员工/新员工入职" → `contact`
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
用户提到"钉钉文档/云文档/知识库/读写文档/块级编辑/文档评论/文档复制移动" → `doc`
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
用户提到"人才池/储备干部池/员工档案元数据或批量模块数据/职业历程/绩效记录/员工标签/组织大脑/结构化人才搜索(高级条件表达式)" → `hrbrain`(区别于 `aisearch` 的通用语义找人与 `contact` 的基础通讯录档案)
用户提到"原生 Markdown 文件/.md 文件/读取 Markdown 原文/覆盖 Markdown/局部替换 Markdown" → `markdown`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件/邮件草稿/转发邮件/回复邮件/邮件附件/抄送" → `mail`
@@ -126,7 +124,6 @@ cli_version: ">=1.0.15"
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
关键区分: chat send-by-bot(机器人身份发消息) vs send-by-webhook(自定义机器人Webhook告警)
关键区分: doc(在线富文本文档/adoc) vs markdown(原生 .md 纯文本文件) vs drive(通用文件存储与传输)
关键区分: contact(基础通讯录档案:学历/家庭/银行卡/紧急联系人/合同/部门角色) vs aisearch person(通用语义找人:谁负责/上级/下级/多维度模糊搜索) vs hrbrain(人才池/员工档案专项模块数据/职业历程/绩效/结构化高级人才搜索)
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
-110
View File
@@ -1,110 +0,0 @@
# Hrbrain(组织大脑)
## 产品说明
Hrbrain 是钉钉组织大脑,提供人才池管理、员工档案查询、人才搜索三大能力,帮助 HR/管理者盘点人才、查询员工履历绩效、按条件检索人员。
**CLI 前缀**: `dws hrbrain`
> ⚠️ **权限粒度**:`talent-pool list` 等接口需要账号单独开通"人才池查看权限",即使账号已登录且其他 hrbrain 接口可用,仍可能因该细粒度权限缺失返回 `errorCode=2002`(无人才池查看权限)。
## 命令总览
### talent-pool (人才池管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `talent-pool list` | 查询人才池列表 | - | 可选 `--keyword`、`--pool-type`、`--creator`、`--labels`(逗号分隔)、`--page`、`--page-size`(默认 1/20) |
| `talent-pool detail` | 获取人才池详情 | `--pool-code` | 根据人才池编码查询 |
| `talent-pool employees` | 查询人才池内人员列表 | `--pool-code` | 可选 `--page`、`--page-size`(默认 1/20) |
### profile (员工档案管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `profile metadata` | 查询员工档案元数据结构 | `--work-no` | 用于构造 `profile query` 的 `--data-queries` |
| `profile query` | 按模块批量查询员工档案数据 | `--work-no` `--data-queries` | `--data-queries` 为 JSON 数组,每项含 `modelCode`(档案模块编码)与 `fields`(字段编码列表) |
| `profile labels` | 获取员工标签 | `--staff-ids` | `--staff-ids` 为逗号分隔工号列表;可选 `--all-label` |
| `profile career` | 查询员工公司内职业历程 | `--work-no` | - |
| `profile performance` | 查询员工绩效记录 | `--work-no` | - |
### search (人才搜索)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `search employees` | 人才搜索 | - | 可选 `--keyword`、`--dept-name`、`--position-name`、`--job-level`、`--pool-code`、`--page`、`--page-size` |
| `search employees-structured` | 使用高级条件搜索人员 | `--origin-json` `--fields` | 建议先调用 `search fields` 获取有权限的字段与操作符列表;`--origin-json` 为搜索表达式 JSON,`--fields` 为返回列定义 JSON 数组;可选 `--order-by`(逗号分隔)、`--page`、`--page-size` |
| `search fields` | 获取高级搜索字段列表 | - | 获取当前操作人有权限使用的字段,用于构造 `search employees-structured` 的参数 |
## 意图判断
用户说"人才池/储备干部池":
- 查看列表 → `talent-pool list`
- 详情 → `talent-pool detail`
- 池内人员 → `talent-pool employees`
用户说"员工档案/档案数据/员工信息模块":
- 元数据结构 → `profile metadata`
- 批量查数据 → `profile query`(先 `metadata` 确认字段编码)
用户说"员工标签/人才标签":
- `profile labels`
用户说"职业历程/职业发展/内部履历":
- `profile career`
用户说"绩效记录/绩效考核历史":
- `profile performance`
用户说"搜人/人才搜索/按条件找人":
- 简单条件(关键词/部门/职务/职级)→ `search employees`
- 复杂组合条件 → 先 `search fields` 获取字段,再 `search employees-structured`
## 核心工作流
```bash
# 人才池列表与详情
dws hrbrain talent-pool list --page 1 --page-size 20 --format json
dws hrbrain talent-pool list --keyword "储备干部" --pool-type TYPE --creator USER_ID --format json
dws hrbrain talent-pool detail --pool-code POOL_CODE --format json
dws hrbrain talent-pool employees --pool-code POOL_CODE --page 1 --page-size 20 --format json
# 员工档案:先查元数据确定字段编码,再批量查数据
dws hrbrain profile metadata --work-no WORK_NO --format json
dws hrbrain profile query --work-no WORK_NO \
--data-queries '[{"modelCode":"basic","fields":["name","dept"]}]' \
--format json
# 员工标签、职业历程、绩效
dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2 --all-label --format json
dws hrbrain profile career --work-no WORK_NO --format json
dws hrbrain profile performance --work-no WORK_NO --format json
# 人才搜索:简单条件
dws hrbrain search employees --keyword "张三" --page 1 --page-size 20 --format json
dws hrbrain search employees --dept-name "技术部" --job-level P7 --pool-code POOL_CODE --format json
# 人才搜索:高级条件,先获取字段列表
dws hrbrain search fields --format json
dws hrbrain search employees-structured \
--origin-json '{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}' \
--fields '[{"label":"姓名","value":"name"}]' \
--page 1 --page-size 20 --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `talent-pool list` | `poolCode` | `talent-pool detail` / `talent-pool employees` 的 `--pool-code` |
| `talent-pool employees` | `workNo` | `profile *` 系列命令的 `--work-no` |
| `profile metadata` | `modelCode`、`fields` | `profile query` 的 `--data-queries` |
| `search fields` | 字段 `value`/`label` 列表 | `search employees-structured` 的 `--fields`、`--origin-json` 中的 `field` |
| `search employees` | `workNo` | `profile *` 系列命令的 `--work-no` |
## 注意事项
- `--data-queries`、`--fields`、`--origin-json` 均为 JSON 字符串参数,必须是合法 JSON,否则命令直接报错。
- `--staff-ids`、`--labels`、`--order-by` 为逗号分隔字符串,非 JSON 数组。
- `--page`/`--page-size` 默认值为 1/20;全局安装的旧版本 `dws` 可能不识别分页 flag,需确认二进制版本已包含 hrbrain 分页支持。
- `talent-pool list` 需要账号单独开通人才池查看权限;返回 `errorCode=2002` 时提示用户联系管理员开通权限,而非重试或换 profile。
-43
View File
@@ -1,43 +0,0 @@
---
name: dingtalk-hrbrain
description: 钉钉组织大脑 Hrbrain。Use when 用户说 人才池/储备干部池/员工档案/职业历程/绩效记录/员工标签/组织大脑/人才搜索。Distinct from dingtalk-contact(通讯录/组织架构)、dingtalk-attendance(考勤)。命令前缀:dws hrbrain。
cli_version: ">=1.0.54-0"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉 Hrbrain(组织大脑)Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。全部 dingtalk-* skill 已通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
> 命令参考:[hrbrain.md](references/hrbrain.md)。
## 意图表
| 用户说 | 命令 |
|--------|------|
| "人才池列表 / 储备干部池" | `dws hrbrain talent-pool list` |
| "人才池详情" | `dws hrbrain talent-pool detail --pool-code <POOL_CODE>` |
| "人才池里有哪些人" | `dws hrbrain talent-pool employees --pool-code <POOL_CODE>` |
| "员工档案元数据 / 档案结构" | `dws hrbrain profile metadata --work-no <WORK_NO>` |
| "查员工档案数据" | 先 `profile metadata` 确认字段编码,再 `dws hrbrain profile query --work-no <WORK_NO> --data-queries '[...]'` |
| "员工标签" | `dws hrbrain profile labels --staff-ids <WORK_NO1,WORK_NO2>` |
| "职业历程 / 内部履历" | `dws hrbrain profile career --work-no <WORK_NO>` |
| "绩效记录" | `dws hrbrain profile performance --work-no <WORK_NO>` |
| "搜人 / 按条件找人(简单)" | `dws hrbrain search employees --keyword <关键词>` |
| "搜人(复杂组合条件)" | 先 `dws hrbrain search fields` 获取字段,再 `dws hrbrain search employees-structured --origin-json '{...}' --fields '[...]'` |
## 权限与约束
- `talent-pool list` 需要账号单独开通"人才池查看权限",返回 `errorCode=2002` 时提示用户联系管理员开通,不要重试或换 profile。
- `--data-queries`、`--fields`、`--origin-json` 必须是合法 JSON 字符串;`--staff-ids`、`--labels`、`--order-by` 是逗号分隔字符串,不是 JSON。
@@ -1,65 +0,0 @@
# Hrbrain(组织大脑)
## 产品说明
Hrbrain 是钉钉组织大脑,提供人才池管理、员工档案查询、人才搜索三大能力。
**CLI 前缀**: `dws hrbrain`
## 命令总览
### talent-pool (人才池管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `talent-pool list` | 查询人才池列表 | - | 可选 `--keyword`、`--pool-type`、`--creator`、`--labels`(逗号分隔)、`--page`、`--page-size` |
| `talent-pool detail` | 获取人才池详情 | `--pool-code` | - |
| `talent-pool employees` | 查询人才池内人员列表 | `--pool-code` | 可选 `--page`、`--page-size` |
### profile (员工档案管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `profile metadata` | 查询员工档案元数据结构 | `--work-no` | 用于构造 `profile query` 的 `--data-queries` |
| `profile query` | 按模块批量查询员工档案数据 | `--work-no` `--data-queries` | `--data-queries` 为 JSON 数组,每项含 `modelCode`、`fields` |
| `profile labels` | 获取员工标签 | `--staff-ids` | 逗号分隔工号列表;可选 `--all-label` |
| `profile career` | 查询员工公司内职业历程 | `--work-no` | - |
| `profile performance` | 查询员工绩效记录 | `--work-no` | - |
### search (人才搜索)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `search employees` | 人才搜索(简单条件) | - |
| `search employees-structured` | 高级结构化搜索 | `--origin-json` `--fields` |
| `search fields` | 获取高级搜索字段列表 | - |
## 意图判断
- "人才池/储备干部池" → `talent-pool list/detail/employees`
- "员工档案/档案数据" → `profile metadata/query`
- "员工标签" → `profile labels`
- "职业历程/内部履历" → `profile career`
- "绩效记录" → `profile performance`
- "搜人/按条件找人" → `search employees`(简单)或 `search fields` + `search employees-structured`(复杂)
## 常用命令
```bash
dws hrbrain talent-pool list --page 1 --page-size 20 --format json
dws hrbrain talent-pool detail --pool-code POOL_CODE --format json
dws hrbrain talent-pool employees --pool-code POOL_CODE --format json
dws hrbrain profile metadata --work-no WORK_NO --format json
dws hrbrain profile query --work-no WORK_NO --data-queries '[{"modelCode":"basic","fields":["name","dept"]}]' --format json
dws hrbrain profile labels --staff-ids WORK_NO1,WORK_NO2 --format json
dws hrbrain profile career --work-no WORK_NO --format json
dws hrbrain profile performance --work-no WORK_NO --format json
dws hrbrain search employees --keyword "张三" --format json
dws hrbrain search fields --format json
dws hrbrain search employees-structured --origin-json '{"rules":[{"field":"name","operator":"contains","value":"张"}],"combinator":"and"}' --fields '[{"label":"姓名","value":"name"}]' --format json
```
## 安全规则
- `--data-queries`、`--fields`、`--origin-json` 必须是合法 JSON;`--staff-ids`、`--labels`、`--order-by` 是逗号分隔字符串。
- `talent-pool list` 需要账号单独开通人才池查看权限(`errorCode=2002` 时提示用户联系管理员开通)。
+1 -80
View File
@@ -1,7 +1,7 @@
[root]
runnable: true
hidden: false
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent
[agoal]
@@ -3740,85 +3740,6 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain]
runnable: true
hidden: false
commands: profile, search, talent-pool
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.profile]
runnable: true
hidden: false
commands: career, labels, metadata, performance, query
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.profile.career]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.profile.labels]
runnable: true
hidden: false
flags: --all-label:bool|required=false|hidden=false|no-opt="true"|scope=local, --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --staff-ids:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.profile.metadata]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.profile.performance]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.profile.query]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --data-queries:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --work-no:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.search]
runnable: true
hidden: false
commands: employees, employees-structured, fields
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.search.employees]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dept-name:string|required=false|hidden=false|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --job-level:string|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --pool-code:string|required=false|hidden=false|no-opt=""|scope=local, --position-name:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.search.employees-structured]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --order-by:string|required=false|hidden=false|no-opt=""|scope=local, --origin-json:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.search.fields]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.talent-pool]
runnable: true
hidden: false
commands: detail, employees, list
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.talent-pool.detail]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --pool-code:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.talent-pool.employees]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --pool-code:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[hrbrain.talent-pool.list]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --creator:string|required=false|hidden=false|no-opt=""|scope=local, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --keyword:string|required=false|hidden=false|no-opt=""|scope=local, --labels:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --page:int|required=false|hidden=false|no-opt=""|scope=local, --page-size:int|required=false|hidden=false|no-opt=""|scope=local, --pool-type:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[live]
runnable: true
hidden: false
-272
View File
@@ -1,272 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestChangedTestPackagesPlansProductionReverseDependencies(t *testing.T) {
repository := newChangedTestPackagesRepository(t)
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
writeChangedTestPackagesFixture(t, repository, "base/base.go", `package base
func Value() string {
return "changed"
}
`)
headRef := commitChangedTestPackagesFixture(t, repository, "change base")
changed := runChangedTestPackages(t, repository, "changed", baseRef, headRef)
if want := []string{"example.com/fixture/base"}; !slices.Equal(changed, want) {
t.Fatalf("changed packages = %q, want %q", changed, want)
}
impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef)
want := []string{
"example.com/fixture/base",
"example.com/fixture/dependent",
"example.com/fixture/testconsumer",
}
if !slices.Equal(impacted, want) {
t.Fatalf("impacted packages = %q, want %q", impacted, want)
}
if slices.Contains(impacted, "example.com/fixture/unrelated") {
t.Fatalf("impacted packages unexpectedly contain unrelated package: %q", impacted)
}
}
func TestChangedTestPackagesMapsEmbeddedAssetsToTheirOwner(t *testing.T) {
repository := newChangedTestPackagesRepository(t)
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
writeChangedTestPackagesFixture(
t,
repository,
"assetowner/assets/message.json",
"{\"message\":\"updated\"}\n",
)
headRef := commitChangedTestPackagesFixture(t, repository, "update embedded asset")
want := []string{"example.com/fixture/assetowner"}
if changed := runChangedTestPackages(t, repository, "changed", baseRef, headRef); !slices.Equal(changed, want) {
t.Fatalf("changed packages = %q, want embedded owner %q", changed, want)
}
if impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef); !slices.Equal(impacted, want) {
t.Fatalf("impacted packages = %q, want embedded owner %q", impacted, want)
}
}
func TestChangedTestPackagesIgnoresDocumentationOnlyDiff(t *testing.T) {
repository := newChangedTestPackagesRepository(t)
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
writeChangedTestPackagesFixture(t, repository, "docs/guide.md", "# Updated guide\n")
headRef := commitChangedTestPackagesFixture(t, repository, "update documentation")
for _, mode := range []string{"changed", "list"} {
if packages := runChangedTestPackages(t, repository, mode, baseRef, headRef); len(packages) != 0 {
t.Errorf("%s packages = %q, want no packages", mode, packages)
}
}
}
func TestChangedTestPackagesRejectsMismatchedOrDirtyCheckout(t *testing.T) {
t.Run("mismatched head", func(t *testing.T) {
repository := newChangedTestPackagesRepository(t)
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
writeChangedTestPackagesFixture(t, repository, "base/base.go", "package base\n\nconst Value = \"changed\"\n")
commitChangedTestPackagesFixture(t, repository, "change base")
output, err := runChangedTestPackagesFailure(repository, "changed", baseRef, baseRef)
if err == nil || !strings.Contains(output, "head does not match the checked-out revision") {
t.Fatalf("mismatched HEAD failure = %v, output = %q", err, output)
}
})
t.Run("dirty tracked file", func(t *testing.T) {
repository := newChangedTestPackagesRepository(t)
headRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
writeChangedTestPackagesFixture(t, repository, "base/base.go", "package base\n\nconst Value = \"dirty\"\n")
output, err := runChangedTestPackagesFailure(repository, "changed", headRef, headRef)
if err == nil || !strings.Contains(output, "requires a clean tracked worktree") {
t.Fatalf("dirty worktree failure = %v, output = %q", err, output)
}
})
}
func TestChangedTestPackagesFailsClosedWhenPackageGraphIsInvalid(t *testing.T) {
repository := newChangedTestPackagesRepository(t)
baseRef := commitChangedTestPackagesFixture(t, repository, "initial fixture")
writeChangedTestPackagesFixture(t, repository, "dependent/dependent.go", `package dependent
import "example.com/fixture/missing"
func Value() string {
return missing.Value()
}
`)
headRef := commitChangedTestPackagesFixture(t, repository, "break package graph")
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
command := exec.Command(script, "list", baseRef, headRef)
command.Dir = repository
output, err := command.CombinedOutput()
if err == nil {
t.Fatalf("invalid package graph unexpectedly succeeded:\n%s", output)
}
if !strings.Contains(string(output), "failed to resolve the module package graph") {
t.Fatalf("invalid package graph failure = %q, want fail-closed diagnostic", output)
}
}
func newChangedTestPackagesRepository(t *testing.T) string {
t.Helper()
repository := t.TempDir()
for path, contents := range map[string]string{
"go.mod": `module example.com/fixture
go 1.22
`,
"base/base.go": `package base
func Value() string {
return "base"
}
`,
"dependent/dependent.go": `package dependent
import "example.com/fixture/base"
func Value() string {
return base.Value()
}
`,
"testconsumer/consumer.go": `package testconsumer
func Value() string {
return "consumer"
}
`,
"testconsumer/consumer_test.go": `package testconsumer
import (
"testing"
"example.com/fixture/base"
)
func TestBaseValue(t *testing.T) {
if base.Value() == "" {
t.Fatal("base value is empty")
}
}
`,
"assetowner/asset.go": `package assetowner
import _ "embed"
//go:embed assets/message.json
var message []byte
func Message() []byte {
return message
}
`,
"assetowner/assets/message.json": "{\"message\":\"initial\"}\n",
"unrelated/unrelated.go": `package unrelated
func Value() string {
return "unrelated"
}
`,
"docs/guide.md": "# Guide\n",
} {
writeChangedTestPackagesFixture(t, repository, path, contents)
}
projectRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve project root: %v", err)
}
script, err := os.ReadFile(filepath.Join(projectRoot, "scripts", "ci", "changed-test-packages.sh"))
if err != nil {
t.Fatalf("read changed package planner: %v", err)
}
scriptPath := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
t.Fatalf("create script directory: %v", err)
}
if err := os.WriteFile(scriptPath, script, 0o755); err != nil {
t.Fatalf("copy changed package planner: %v", err)
}
runChangedTestPackagesCommand(t, repository, "git", "init", "-q", "-b", "main")
runChangedTestPackagesCommand(t, repository, "git", "config", "user.name", "DWS CI")
runChangedTestPackagesCommand(t, repository, "git", "config", "user.email", "dws-ci@example.invalid")
return repository
}
func writeChangedTestPackagesFixture(t *testing.T, repository, path, contents string) {
t.Helper()
fullPath := filepath.Join(repository, filepath.FromSlash(path))
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
t.Fatalf("create directory for %s: %v", path, err)
}
if err := os.WriteFile(fullPath, []byte(contents), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func commitChangedTestPackagesFixture(t *testing.T, repository, message string) string {
t.Helper()
runChangedTestPackagesCommand(t, repository, "git", "add", ".")
runChangedTestPackagesCommand(t, repository, "git", "commit", "-q", "-m", message)
return strings.TrimSpace(runChangedTestPackagesCommand(t, repository, "git", "rev-parse", "HEAD"))
}
func runChangedTestPackages(t *testing.T, repository, mode, baseRef, headRef string) []string {
t.Helper()
output := runChangedTestPackagesCommand(
t,
repository,
filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh"),
mode,
baseRef,
headRef,
)
return strings.Fields(output)
}
func runChangedTestPackagesFailure(repository, mode, baseRef, headRef string) (string, error) {
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
command := exec.Command(script, mode, baseRef, headRef)
command.Dir = repository
output, err := command.CombinedOutput()
return string(output), err
}
func runChangedTestPackagesCommand(t *testing.T, directory, name string, args ...string) string {
t.Helper()
command := exec.Command(name, args...)
command.Dir = directory
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, output)
}
return string(output)
}
+1 -42
View File
@@ -434,52 +434,11 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
`COVERAGE_TARGET: "100"`,
`COVERAGE_ENFORCE_OVERALL: "false"`,
`COVERAGE_OVERALL_TOLERANCE: "0"`,
`additional_profile=coverage-shortcut.txt`,
`COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`,
"run: make test-plan",
"run: make format-check",
"./scripts/ci/test-packages.sh list",
`package_output="$(./scripts/ci/test-packages.sh list`,
"docs_only: ${{ steps.classify.outputs.docs_only }}",
"full_suite: ${{ steps.classify.outputs.full_suite }}",
"classifyFiles(files.length < 300)",
"platformSensitive =",
"files.some(isNativeGoChange)",
"paths.some(isEditionSensitive)",
`/_(darwin|windows|linux|unix)(?:_[a-z0-9]+)*\.go$/`,
"filename.startsWith('build/')",
"filename === '.github/actionlint.yaml'",
"filename.startsWith('scripts/')",
"filename.startsWith('verify/')",
"filename === 'test/fixtures/cli-interface-baseline.txt'",
"filename.startsWith('internal/interfacesnapshot/')",
"filename.startsWith('internal/cobracmd/')",
"filename.startsWith('internal/i18n/')",
"filename.startsWith('pkg/cmdutil/')",
"filename === 'skills_embed.go'",
"filename.startsWith('test/mock_mcp/')",
"name: Test (changed packages)",
"changed-test-packages.sh",
"Verify authoritative synthetic merge",
`test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`,
`test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`,
`echo "TEST_HEAD_REF=$(git rev-parse HEAD)"`,
"list \"$TEST_BASE_REF\" \"$TEST_HEAD_REF\"",
"needs.lint.outputs.full_suite != 'true'",
`name: "Test (race: ${{ matrix.shard }})"`,
"name: Test (workflow and release contracts)",
"needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.release_sensitive == 'true'",
"needs.lint.outputs.full_suite == 'true'",
"-coverpkg=\"$coverpkg\"",
"changed \"$COVERAGE_BASE_REF\" \"$COVERAGE_HEAD_REF\"",
"list \"$COVERAGE_BASE_REF\" \"$COVERAGE_HEAD_REF\"",
"current_expected=success",
"supporting_expected=skipped",
"baseline_expected=success",
"Validate scoped policy",
"Interface Integrity is unaffected by this classified revision.",
"CLI Smoke is unaffected by this classified revision.",
"Mock MCP is unaffected by this classified revision.",
"Edition is unaffected by this classified revision.",
} {
if !strings.Contains(admission, want) {
t.Errorf("Code Admission workflow missing contract %q", want)
-95
View File
@@ -1,95 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package scripts_test
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestGitHubScriptWorkflowJavaScriptParses(t *testing.T) {
t.Parallel()
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node is required to syntax-check actions/github-script steps")
}
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
paths, err := filepath.Glob(filepath.Join(root, ".github", "workflows", "*.yml"))
if err != nil {
t.Fatalf("glob workflows: %v", err)
}
checked := 0
for _, path := range paths {
data, readErr := os.ReadFile(path)
if readErr != nil {
t.Fatalf("read %s: %v", path, readErr)
}
var workflow any
if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil {
t.Fatalf("parse %s: %v", path, unmarshalErr)
}
var scripts []string
collectGitHubScripts(workflow, &scripts)
for index, script := range scripts {
checked++
source := fmt.Sprintf(
"async function workflowScript%d() {\n%s\n}\n",
index,
script,
)
scriptPath := filepath.Join(
t.TempDir(),
fmt.Sprintf("%s-%d.js", filepath.Base(path), index),
)
if writeErr := os.WriteFile(scriptPath, []byte(source), 0o600); writeErr != nil {
t.Fatalf("write syntax fixture for %s: %v", path, writeErr)
}
command := exec.Command(node, "--check", scriptPath)
if output, runErr := command.CombinedOutput(); runErr != nil {
t.Errorf(
"github-script syntax failed for %s step %d: %v\n%s",
path,
index,
runErr,
output,
)
}
}
}
if checked == 0 {
t.Fatal("no actions/github-script steps were found")
}
}
func collectGitHubScripts(value any, scripts *[]string) {
switch typed := value.(type) {
case map[string]any:
uses, _ := typed["uses"].(string)
if strings.HasPrefix(uses, "actions/github-script@") {
if with, ok := typed["with"].(map[string]any); ok {
if script, ok := with["script"].(string); ok {
*scripts = append(*scripts, script)
}
}
}
for _, child := range typed {
collectGitHubScripts(child, scripts)
}
case []any:
for _, child := range typed {
collectGitHubScripts(child, scripts)
}
}
}
+58 -244
View File
@@ -9,7 +9,6 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
@@ -698,11 +697,11 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
)
if got := strings.Count(workflow, checksCall); got != 3 {
t.Fatalf("release workflow Checks API call count = %d, want one tag check, one preflight check, and one Formula-parent check", got)
if got := strings.Count(workflow, checksCall); got != 2 {
t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got)
}
if got := strings.Count(workflow, paginatedCall); got != 3 {
t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check, one preflight check, and one Formula-parent check", got)
if got := strings.Count(workflow, paginatedCall); got != 2 {
t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check and one preflight check", got)
}
if got := strings.Count(workflow, immutableCall); got != 2 {
t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got)
@@ -765,7 +764,7 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
t,
sections["tag"],
" - name: Require immutable releases governance\n",
"\n - name: Require successful beta delivery before stable promotion\n",
"\n - name: Verify Homebrew PR automation permission\n",
)
for name, section := range map[string]string{
"sealed Code Admission recheck": tagChecks,
@@ -859,18 +858,9 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
}
}
admission := strings.Index(preflight, "Require successful Code Admission contexts on the preflight commit")
immutableGovernance := strings.Index(preflight, "Require immutable releases governance")
if admission == -1 || immutableGovernance == -1 || admission > immutableGovernance {
t.Error("governance preflight must validate all exact Code Admission contexts before immutable-release governance")
}
for _, forbidden := range []string{
"HOMEBREW_PR_TOKEN",
"verify-homebrew-pr-token.sh",
"DWS_TAP_PR_",
} {
if strings.Contains(preflight, forbidden) {
t.Errorf("governance preflight must not retain obsolete Homebrew PR machinery %q", forbidden)
}
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
if admission == -1 || homebrewCanary == -1 || admission > homebrewCanary {
t.Error("governance preflight must validate all exact Code Admission contexts before exposing Homebrew credentials")
}
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
@@ -971,46 +961,6 @@ func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
t.Errorf("cloud release seal is missing %q", required)
}
}
createRef := strings.Index(seal, "github.rest.git.createRef")
visibilityRead := strings.LastIndex(seal, "github.rest.git.getRef")
if createRef == -1 || visibilityRead == -1 || visibilityRead < createRef {
t.Fatal("cloud release seal must confirm tag-ref visibility after creating the ref")
}
postCreate := seal[createRef:]
for _, required := range []string{
"error.status === 404",
"error.status === 429",
"error.status >= 500",
"setTimeout",
`.data.object.type !== "tag"`,
"!== expectedTagObject",
".data.tag !== version",
".data.object.sha !== commit",
".data.message !==",
} {
if !strings.Contains(seal, required) {
t.Errorf("post-create tag verification is missing %q", required)
}
}
if strings.Count(postCreate, "github.rest.git.getRef") < 2 {
t.Error("post-create tag handling must both adopt an exact uncertain write and confirm final ref visibility")
}
if !regexp.MustCompile(`(?m)const \w*[Rr]etry\w*[Dd]elays\w* = \[(?:0,?\s*){1}[\d,\s]+\];`).MatchString(seal) {
t.Error("post-create tag verification must use a bounded retry policy")
}
for _, required := range []string{
"`${version} tag ref`",
"() => github.rest.git.getRef",
} {
if !strings.Contains(postCreate, required) {
t.Errorf("the bounded visibility retry must be used after createRef: missing %q", required)
}
}
retryLoop := strings.LastIndex(seal[:createRef], "for (const delayMs of")
retrySleep := strings.LastIndex(seal[:createRef], "await sleep(delayMs)")
if retryLoop == -1 || retrySleep == -1 || retryLoop > retrySleep {
t.Error("the bounded visibility retry must be used after createRef, not only declared")
}
for _, forbidden := range []string{
"actions/checkout",
"github.rest.git.updateRef",
@@ -1231,7 +1181,7 @@ func TestReleaseWorkflowRequiresOSSOnlyWhenMirrorIsEnabled(t *testing.T) {
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n governance-preflight:\n")
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n")
start := strings.Index(workflow, " repair-channel:\n")
if start == -1 {
t.Fatal("release workflow is missing the channel repair job")
@@ -1399,6 +1349,10 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
"workflow_dispatch must select exactly one release mode",
"release recovery confirmation must equal the exact version",
"recover_release_nonce must be bound to the release commit",
"environment: release-recovery",
"prevent_self_review !== true",
"protected_branches !== true",
"can_admins_bypass !== false",
`run.path !== ".github/workflows/release.yml"`,
`const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"`,
`run.event !== expectedEvent`,
@@ -1428,16 +1382,6 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
t.Errorf("release recovery contract is missing %q", required)
}
}
for _, forbidden := range []string{
"authorize-recovery:",
"environment: release-recovery",
"needs.authorize-recovery",
"AUTHORIZE_RECOVERY_RESULT",
} {
if strings.Contains(workflow, forbidden) {
t.Errorf("machine-verified release recovery must not wait on manual approval %q", forbidden)
}
}
sections := map[string]string{
"release": releaseWorkflowSection(t, workflow, " release:\n", "\n verify-darwin-signatures:\n"),
@@ -1467,49 +1411,6 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
}
}
func TestReleaseWorkflowRerunAdoptsOnlyItsExactSeal(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
sealStart := strings.Index(workflow, " seal-release:\n")
if sealStart == -1 {
t.Fatal("release workflow is missing the cloud seal job")
}
seal := workflow[sealStart:]
for _, required := range []string{
`const currentAttempt = Number(process.env.GITHUB_RUN_ATTEMPT)`,
`Release-Run: ${context.runId}`,
`Release-Run-Attempt: ${attempt}`,
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
`Workflow-Commit: ${context.sha}`,
`Allocation-Fingerprint: ${expectedFingerprint}`,
`ref.ref === ` + "`refs/tags/${version}`",
`existingRef = await github.rest.git.getRef`,
`sealedAttempt > currentAttempt`,
`.data.message !== messageForAttempt(sealedAttempt)`,
`if (branchMoved)`,
`if (!existingRef)`,
`basehead: ` + "`${commit}...${currentBranchCommit}`",
`["ahead", "identical"].includes(comparison.data.status)`,
`if (existingRef)`,
`continuing failed jobs without recovery approval`,
} {
if !strings.Contains(seal, required) {
t.Errorf("same-run release rerun exact-seal contract is missing %q", required)
}
}
for _, forbidden := range []string{
"github.rest.git.updateRef",
"github.rest.git.deleteRef",
"environment: release-recovery",
"needs.authorize-recovery",
} {
if strings.Contains(seal, forbidden) {
t.Errorf("same-run exact seal reuse must not mutate the tag or require approval: found %q", forbidden)
}
}
}
func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
@@ -1646,7 +1547,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
name: "release contract",
start: " release-contract:\n",
end: "\n release:\n",
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}`,
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}`,
},
{
name: "build",
@@ -1699,6 +1600,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
"name: Release delivery gate",
`if: ${{ !cancelled() }}`,
"- dispatch-contract",
"- authorize-recovery",
"- governance-preflight",
"- release-contract",
"- release-validation",
@@ -1737,16 +1639,6 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
t.Errorf("release delivery gate is missing %q", required)
}
}
for _, forbidden := range []string{
"- authorize-recovery",
"AUTHORIZE_RECOVERY_RESULT",
"needs.authorize-recovery",
"require_result authorize-recovery",
} {
if strings.Contains(gate, forbidden) {
t.Errorf("release delivery gate must not retain recovery approval dependency %q", forbidden)
}
}
}
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
@@ -1948,10 +1840,10 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
"Publish or reuse immutable GitHub Release",
"gh release upload",
"Publish missing version to npm channel",
"Publish stable Homebrew formula",
"Publish beta Homebrew formula",
"Open stable Homebrew formula PR",
"Open beta Homebrew formula PR",
"DingTalk-Real-AI/dingtalk-workspace-cli.git",
"secrets.GITHUB_TOKEN",
"secrets.HOMEBREW_PR_TOKEN",
} {
if !strings.Contains(publishSection, required) {
t.Errorf("post-verification publication stage is missing %q", required)
@@ -1959,7 +1851,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
}
}
func TestReleaseWorkflowPublishesStableHomebrewFormulaDirectly(t *testing.T) {
func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
@@ -1971,45 +1863,61 @@ func TestReleaseWorkflowPublishesStableHomebrewFormulaDirectly(t *testing.T) {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
for _, forbidden := range []string{
if strings.Contains(workflow, "pull-requests: write") {
t.Fatal("the built-in GITHUB_TOKEN must not receive pull-request write permission")
}
for _, required := range []string{
"Verify Homebrew PR automation permission",
"secrets.HOMEBREW_PR_TOKEN",
"verify-homebrew-pr-token.sh",
"DWS_TAP_PR_REPOSITORY",
"DWS_TAP_PR_BRANCH",
"Open stable Homebrew formula PR",
"Open beta Homebrew formula PR",
"--canary",
"HOMEBREW_PR_TOKEN and RELEASE_GOVERNANCE_TOKEN must use separate least-privilege identities",
} {
if strings.Contains(workflow, forbidden) {
t.Errorf("release workflow must not retain Homebrew PR machinery %q", forbidden)
if !strings.Contains(workflow, required) {
t.Errorf("release workflow is missing Homebrew PR token preflight %q", required)
}
}
start := strings.Index(workflow, "- name: Publish stable Homebrew formula")
if start == -1 {
t.Fatal("release workflow is missing direct stable Homebrew publication")
if got := strings.Count(workflow, "Verify Homebrew PR automation permission"); got != 2 {
t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got)
}
end := strings.Index(workflow[start:], "- name: Publish beta Homebrew formula")
tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
tagAdmission := strings.Index(tagContract, "Require successful Code Admission contexts on the sealed commit")
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
if tagAdmission == -1 || tagHomebrew == -1 || tagAdmission > tagHomebrew {
t.Error("tag contract must validate all exact Code Admission contexts before exposing Homebrew credentials")
}
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
if start == -1 {
t.Fatal("release workflow is missing the stable Homebrew PR step")
}
end := strings.Index(workflow[start:], "- name: Open beta Homebrew formula PR")
if end == -1 {
t.Fatal("release workflow is missing direct beta Homebrew publication after the stable step")
t.Fatal("release workflow is missing the beta Homebrew PR step after the stable step")
}
section := workflow[start : start+end]
for _, required := range []string{
"github.repository_owner == 'DingTalk-Real-AI'",
"needs.release-contract.outputs.channel == 'stable'",
"scripts/release/publish-homebrew-formula.sh",
"./scripts/release/publish-homebrew-formula.sh",
"secrets.HOMEBREW_PR_TOKEN",
"DWS_TAP_PR_REPOSITORY",
"automation/homebrew-${{ needs.release-contract.outputs.release_version }}",
} {
if !strings.Contains(section, required) {
t.Errorf("direct stable Homebrew publication is missing %q", required)
t.Errorf("Homebrew publication step is missing %q", required)
}
}
if strings.Contains(section, "secrets.GITHUB_TOKEN") {
t.Error("Homebrew Formula PRs must use the dedicated token so their CI is triggered")
}
stableNPM := strings.Index(workflow, "- name: Publish missing version to npm channel")
if stableNPM == -1 || start > stableNPM {
t.Fatal("Homebrew publication must run before npm so a failure is safely rerunnable")
t.Fatal("Homebrew PR creation must run before npm so a failure is safely rerunnable")
}
}
func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
func TestReleaseWorkflowOpensVersionedHomebrewPRForBetaTags(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
@@ -2022,9 +1930,9 @@ func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
}
workflow := string(data)
start := strings.Index(workflow, "- name: Publish beta Homebrew formula")
start := strings.Index(workflow, "- name: Open beta Homebrew formula PR")
if start == -1 {
t.Fatal("release workflow is missing direct beta Homebrew publication")
t.Fatal("release workflow is missing the beta Homebrew PR step")
}
end := strings.Index(workflow[start:], "- name: Reverify exact immutable npm package")
if end == -1 {
@@ -2037,108 +1945,14 @@ func TestReleaseWorkflowPublishesBetaHomebrewFormulaDirectly(t *testing.T) {
"dist/homebrew/dingtalk-workspace-cli-beta.rb",
"Formula/dingtalk-workspace-cli-beta.rb",
"secrets.HOMEBREW_PR_TOKEN",
"automation/homebrew-beta-${{ needs.release-contract.outputs.release_version }}",
} {
if !strings.Contains(section, required) {
t.Errorf("direct beta Homebrew publication is missing %q", required)
t.Errorf("beta Homebrew PR step is missing %q", required)
}
}
for _, forbidden := range []string{
"DWS_TAP_PR_REPOSITORY",
"DWS_TAP_PR_BRANCH",
"automation/homebrew-beta-",
} {
if strings.Contains(section, forbidden) {
t.Errorf("direct beta Homebrew publication must not open a PR: found %q", forbidden)
}
}
}
func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
if !strings.Contains(publishJob, "checks: write") {
t.Fatal("Formula-only delivery must receive checks: write in the already write-capable publication job")
}
for _, required := range []string{
"id: homebrew-stable",
"id: homebrew-beta",
`FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`,
`FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`,
} {
if !strings.Contains(publishJob, required) {
t.Errorf("Homebrew publisher output wiring is missing %q", required)
}
}
seal := releaseWorkflowSection(
t,
publishJob,
" - name: Seal Formula-only Code Admission contexts\n",
"\n - name: Reverify exact immutable npm package\n",
)
for _, required := range []string{
`const commit = process.env.FORMULA_COMMIT`,
`commitResponse.data.sha === commit`,
`commitResponse.data.parents.length === 1`,
`commitResponse.data.commit.message === expectedMessage`,
`commitResponse.data.author?.login === "github-actions[bot]"`,
`commitResponse.data.committer?.login === "github-actions[bot]"`,
`files.length === 1`,
`files[0].filename === formulaPath`,
`["added", "modified"].includes(files[0].status)`,
`const parent = commitResponse.data.parents[0].sha`,
`github.rest.checks.listForRef`,
`ref: parent`,
`run.head_sha !== parent`,
`run.app?.slug !== "github-actions"`,
`run.conclusion !== "success"`,
`github.rest.repos.getContent`,
`path: formulaPath`,
`ref: commit`,
`actualFormula.equals(expectedFormula)`,
`github.rest.repos.compareCommitsWithBasehead`,
`basehead: ` + "`${commit}...${branch.data.commit.sha}`",
`["ahead", "identical"].includes(comparison.data.status)`,
`github.rest.checks.create`,
`head_sha: commit`,
`status: "completed"`,
`conclusion: "success"`,
} {
if !strings.Contains(seal, required) {
t.Errorf("Formula-only Code Admission sealing is missing %q", required)
}
}
for _, context := range expectedReleaseAdmissionContexts {
if strings.Count(seal, fmt.Sprintf("%q", context)) != 1 {
t.Errorf("Formula-only Code Admission must derive exactly one %q context from the reviewed list", context)
}
}
if strings.Count(seal, "github.rest.checks.create") != 1 {
t.Error("Formula-only checks must be created only by the single verified context loop")
}
for _, forbidden := range []string{
"head_sha: context.sha",
"head_sha: parent",
"head_sha: branch.data.commit.sha",
} {
if strings.Contains(seal, forbidden) {
t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden)
}
}
createCheck := strings.Index(seal, "github.rest.checks.create")
for name, marker := range map[string]string{
"single-parent Formula-only identity": "const exactFormulaCommit",
"successful parent contexts": "invalidParentContexts.length > 0",
"byte-for-byte Formula content": "actualFormula.equals(expectedFormula)",
"default-branch containment": `["ahead", "identical"].includes(comparison.data.status)`,
} {
index := strings.Index(seal, marker)
if index == -1 || createCheck == -1 || index > createCheck {
t.Errorf("%s must be verified before any success check is created", name)
}
if strings.Contains(section, "secrets.GITHUB_TOKEN") {
t.Error("Homebrew beta Formula PRs must use the dedicated token so their CI is triggered")
}
}
+3 -171
View File
@@ -8,7 +8,7 @@ import (
"testing"
)
func TestPublishHomebrewFormulaPublishesDirectlyWithoutPR(t *testing.T) {
func TestPublishHomebrewFormulaPushesUpdatedFormula(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "publish-homebrew-formula.sh"))
@@ -23,22 +23,12 @@ func TestPublishHomebrewFormulaPublishesDirectlyWithoutPR(t *testing.T) {
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
mustWriteFile(t, sourceFormula, []byte("class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"), 0o644)
publishOutput := filepath.Join(root, "publish-output")
fakeBin := filepath.Join(root, "bin")
ghLog := filepath.Join(root, "gh.log")
mustWriteFile(t, filepath.Join(fakeBin, "gh"), []byte(`#!/bin/sh
printf '%s\n' "$*" >> "$GH_LOG"
exit 97
`), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"GH_LOG="+ghLog,
"DWS_TAP_REPO_URL="+remoteDir,
"DWS_TAP_BRANCH=main",
"DWS_FORMULA_SOURCE="+sourceFormula,
"GITHUB_OUTPUT="+publishOutput,
"DWS_GIT_NAME=DWS Bot",
"DWS_GIT_EMAIL=dws@example.com",
)
@@ -60,28 +50,6 @@ exit 97
if string(got) != "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n" {
t.Fatalf("published formula = %q", string(got))
}
publishedCommit := strings.TrimSpace(mustOutput(t, cloneDir, "git", "rev-parse", "HEAD"))
result, err := os.ReadFile(publishOutput)
if err != nil {
t.Fatalf("ReadFile(publish output) error = %v", err)
}
wantResult := "formula_changed=true\npublished_commit=" + publishedCommit + "\n"
if string(result) != wantResult {
t.Fatalf("publish result = %q, want %q", result, wantResult)
}
parentLine := strings.Fields(mustOutput(t, cloneDir, "git", "rev-list", "--parents", "-n", "1", "HEAD"))
if len(parentLine) != 2 {
t.Fatalf("published Formula commit parent fields = %v, want exactly one parent", parentLine)
}
changedPaths := strings.TrimSpace(mustOutput(t, cloneDir, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"))
if changedPaths != "Formula/dingtalk-workspace-cli.rb" {
t.Fatalf("published Formula commit changed %q", changedPaths)
}
if calls, err := os.ReadFile(ghLog); err == nil {
t.Fatalf("direct Homebrew publication unexpectedly invoked gh:\n%s", calls)
} else if !os.IsNotExist(err) {
t.Fatalf("ReadFile(gh log) error = %v", err)
}
}
func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
@@ -95,27 +63,11 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
root := t.TempDir()
remoteDir := filepath.Join(root, "tap.git")
mustRun(t, root, "git", "init", "--bare", remoteDir)
seedTapRepo(t, remoteDir, "main", "class OldFormula < Formula\nend\n")
initialFormula := "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"
historyDir := filepath.Join(root, "history")
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, historyDir)
mustRun(t, historyDir, "git", "config", "user.name", "DWS Bot")
mustRun(t, historyDir, "git", "config", "user.email", "dws@example.com")
mustWriteFile(t, filepath.Join(historyDir, "Formula", "dingtalk-workspace-cli.rb"), []byte(initialFormula), 0o644)
mustRun(t, historyDir, "git", "add", "Formula/dingtalk-workspace-cli.rb")
mustRun(t, historyDir, "git", "commit", "-m", "chore: update formula for v1.2.3 [skip ci]")
formulaCommit := strings.TrimSpace(mustOutput(t, historyDir, "git", "rev-parse", "HEAD"))
mustRun(t, historyDir, "git", "config", "user.name", "Another Maintainer")
mustRun(t, historyDir, "git", "config", "user.email", "maintainer@example.com")
mustWriteFile(t, filepath.Join(historyDir, "README.md"), []byte("unrelated main update\n"), 0o644)
mustRun(t, historyDir, "git", "add", "README.md")
mustRun(t, historyDir, "git", "commit", "-m", "docs: unrelated main update")
mustRun(t, historyDir, "git", "push", "origin", "main")
seedTapRepo(t, remoteDir, "main", initialFormula)
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
mustWriteFile(t, sourceFormula, []byte(initialFormula), 0o644)
publishOutput := filepath.Join(root, "publish-output")
beforeHead := strings.TrimSpace(mustOutput(t, root, "git", "ls-remote", remoteDir, "refs/heads/main"))
@@ -124,7 +76,6 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
"DWS_TAP_REPO_URL="+remoteDir,
"DWS_TAP_BRANCH=main",
"DWS_FORMULA_SOURCE="+sourceFormula,
"DWS_PUBLISH_OUTPUT="+publishOutput,
"DWS_GIT_NAME=DWS Bot",
"DWS_GIT_EMAIL=dws@example.com",
)
@@ -140,125 +91,6 @@ func TestPublishHomebrewFormulaSkipsWhenFormulaUnchanged(t *testing.T) {
if beforeHead != afterHead {
t.Fatalf("remote head changed unexpectedly:\nbefore: %s\nafter: %s", beforeHead, afterHead)
}
result, err := os.ReadFile(publishOutput)
if err != nil {
t.Fatalf("ReadFile(publish output) error = %v", err)
}
wantResult := "formula_changed=false\npublished_commit=" + formulaCommit + "\n"
if string(result) != wantResult {
t.Fatalf("no-op publish result = %q, want last matching Formula-only bot commit %q", result, wantResult)
}
}
func TestPublishHomebrewFormulaRetriesNonFastForwardFromFreshMain(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "publish-homebrew-formula.sh"))
if err != nil {
t.Fatalf("Abs(publish-homebrew-formula.sh) error = %v", err)
}
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatalf("LookPath(git) error = %v", err)
}
root := t.TempDir()
remoteDir := filepath.Join(root, "tap.git")
mustRun(t, root, "git", "init", "--bare", remoteDir)
seedTapRepo(t, remoteDir, "main", "class OldFormula < Formula\nend\n")
raceDir := filepath.Join(root, "racer")
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, raceDir)
mustRun(t, raceDir, "git", "config", "user.name", "Concurrent Maintainer")
mustRun(t, raceDir, "git", "config", "user.email", "concurrent@example.com")
mustWriteFile(t, filepath.Join(raceDir, "README.md"), []byte("concurrent main update\n"), 0o644)
mustRun(t, raceDir, "git", "add", "README.md")
mustRun(t, raceDir, "git", "commit", "-m", "docs: concurrent main update")
raceCommit := strings.TrimSpace(mustOutput(t, raceDir, "git", "rev-parse", "HEAD"))
sourceFormula := filepath.Join(root, "dingtalk-workspace-cli.rb")
newFormula := "class DingtalkWorkspaceCli < Formula\n desc \"DingTalk Workspace CLI\"\nend\n"
mustWriteFile(t, sourceFormula, []byte(newFormula), 0o644)
publishOutput := filepath.Join(root, "publish-output")
fakeBin := filepath.Join(root, "bin")
gitLog := filepath.Join(root, "git.log")
raceOnce := filepath.Join(root, "race-once")
mustWriteFile(t, filepath.Join(fakeBin, "git"), []byte(`#!/bin/sh
set -eu
printf '%s\n' "$*" >> "$GIT_LOG"
if [ "${1:-}" = push ] && [ "${2:-}" = origin ] &&
[ "${3:-}" = "HEAD:main" ] && [ ! -e "$GIT_RACE_ONCE" ]; then
: > "$GIT_RACE_ONCE"
"$REAL_GIT" -C "$RACE_REPO" push origin main >/dev/null
exit 1
fi
exec "$REAL_GIT" "$@"
`), 0o755)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"REAL_GIT="+realGit,
"RACE_REPO="+raceDir,
"GIT_LOG="+gitLog,
"GIT_RACE_ONCE="+raceOnce,
"DWS_TAP_REPO_URL="+remoteDir,
"DWS_TAP_BRANCH=main",
"DWS_FORMULA_SOURCE="+sourceFormula,
"DWS_PUBLISH_OUTPUT="+publishOutput,
"DWS_GIT_NAME=DWS Bot",
"DWS_GIT_EMAIL=dws@example.com",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("publish-homebrew-formula.sh race recovery error = %v\noutput:\n%s", err, string(output))
}
if !strings.Contains(string(output), "retrying from a fresh clone") ||
!strings.Contains(string(output), "Published Homebrew formula") {
t.Fatalf("publisher did not report bounded fresh-clone recovery:\n%s", output)
}
checkDir := filepath.Join(root, "check")
mustRun(t, root, "git", "clone", "--branch", "main", remoteDir, checkDir)
publishedCommit := strings.TrimSpace(mustOutput(t, checkDir, "git", "rev-parse", "HEAD"))
parent := strings.TrimSpace(mustOutput(t, checkDir, "git", "rev-parse", "HEAD^"))
if parent != raceCommit {
t.Fatalf("retried Formula commit parent = %s, want concurrent main %s", parent, raceCommit)
}
parentLine := strings.Fields(mustOutput(t, checkDir, "git", "rev-list", "--parents", "-n", "1", "HEAD"))
if len(parentLine) != 2 {
t.Fatalf("retried Formula commit parent fields = %v, want exactly one parent", parentLine)
}
changedPaths := strings.TrimSpace(mustOutput(t, checkDir, "git", "diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"))
if changedPaths != "Formula/dingtalk-workspace-cli.rb" {
t.Fatalf("retried Formula commit changed %q", changedPaths)
}
formula, err := os.ReadFile(filepath.Join(checkDir, "Formula", "dingtalk-workspace-cli.rb"))
if err != nil {
t.Fatalf("ReadFile(published formula) error = %v", err)
}
if string(formula) != newFormula {
t.Fatalf("published formula = %q, want %q", formula, newFormula)
}
result, err := os.ReadFile(publishOutput)
if err != nil {
t.Fatalf("ReadFile(publish output) error = %v", err)
}
wantResult := "formula_changed=true\npublished_commit=" + publishedCommit + "\n"
if string(result) != wantResult {
t.Fatalf("race-recovered publish result = %q, want %q", result, wantResult)
}
gitCalls, err := os.ReadFile(gitLog)
if err != nil {
t.Fatalf("ReadFile(git log) error = %v", err)
}
if strings.Contains(string(gitCalls), "--force") {
t.Fatalf("direct Formula publication must never force push:\n%s", gitCalls)
}
if got := strings.Count(string(gitCalls), "push origin HEAD:main"); got != 2 {
t.Fatalf("direct Formula push attempts = %d, want one rejected attempt and one retry\n%s", got, gitCalls)
}
}
func TestPublishHomebrewFormulaOpensPRWithoutWritingMain(t *testing.T) {
@@ -1,143 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package scripts_test
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
type reviewerRouterWorkflow struct {
On map[string]reviewerRouterTrigger `yaml:"on"`
Permissions map[string]string `yaml:"permissions"`
Jobs map[string]reviewerRouterJob `yaml:"jobs"`
}
type reviewerRouterTrigger struct {
Branches []string `yaml:"branches"`
Types []string `yaml:"types"`
}
type reviewerRouterJob struct {
If string `yaml:"if"`
Steps []reviewerRouterStep `yaml:"steps"`
}
type reviewerRouterStep struct {
Uses string `yaml:"uses"`
With map[string]string `yaml:"with"`
}
func TestReviewerRouterWorkflowContract(t *testing.T) {
t.Parallel()
path, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "reviewer-router.yml"))
if err != nil {
t.Fatalf("Abs(reviewer-router.yml) error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", path, err)
}
var workflow reviewerRouterWorkflow
if err := yaml.Unmarshal(data, &workflow); err != nil {
t.Fatalf("yaml.Unmarshal(%s) error = %v", path, err)
}
if len(workflow.On) != 1 {
t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On)
}
trigger, ok := workflow.On["pull_request_target"]
if !ok {
t.Fatalf("workflow triggers = %v, want pull_request_target", workflow.On)
}
if wantBranches := []string{"main"}; !reflect.DeepEqual(trigger.Branches, wantBranches) {
t.Fatalf("pull_request_target branches = %v, want %v", trigger.Branches, wantBranches)
}
wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review"}
if !reflect.DeepEqual(trigger.Types, wantTypes) {
t.Fatalf("pull_request_target types = %v, want %v", trigger.Types, wantTypes)
}
wantPermissions := map[string]string{
"contents": "write",
"pull-requests": "write",
}
if !reflect.DeepEqual(workflow.Permissions, wantPermissions) {
t.Fatalf("workflow permissions = %v, want exactly %v", workflow.Permissions, wantPermissions)
}
if len(workflow.Jobs) != 1 {
t.Fatalf("workflow jobs = %v, want one isolated routing job", workflow.Jobs)
}
job, ok := workflow.Jobs["route"]
if !ok {
t.Fatalf("workflow jobs = %v, want route", workflow.Jobs)
}
if job.If != "github.event.pull_request.draft == false" {
t.Fatalf("route.if = %q, want non-draft guard", job.If)
}
const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b"
if len(job.Steps) != 1 || job.Steps[0].Uses != githubScriptSHA {
t.Fatalf("route steps = %#v, want one pinned base-owned github-script step", job.Steps)
}
script := job.Steps[0].With["script"]
for _, want := range []string{
"'sczheng189'",
"'shangguanxuan633-lab'",
"'audanye-sudo'",
"'wxianfeng'",
"github.rest.pulls.get",
"currentPull.head.sha !== eventHeadSha",
"currentPull.state !== 'open'",
"currentPull.draft",
"currentPull.base.ref !== 'main'",
"getReadyEventPull('review request')",
"getReadyEventPull('auto-merge enable')",
"context.payload.action === 'synchronize'",
"context.payload.sender?.login?.toLowerCase()",
"reviewer.toLowerCase() !== author",
"reviewer.toLowerCase() !== latestPusher",
"pullRequest.requested_reviewers",
"pullRequest.requested_teams",
"github.rest.pulls.listReviews",
"['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes",
"review.commit_id === headSha",
"['APPROVED', 'CHANGES_REQUESTED'].includes(review.state)",
"review.state === 'CHANGES_REQUESTED'",
"staleChangeRequester",
"state: 'open'",
"loads.set(candidate, loads.get(candidate) + 1)",
"loads.get(left) - loads.get(right)",
"for (const reviewer of ranked)",
"github.rest.pulls.requestReviewers",
"trying the next candidate",
"Reviewer routing hit an unexpected error",
"enablePullRequestAutoMerge",
"mergeMethod: MERGE",
"core.warning",
} {
if !strings.Contains(script, want) {
t.Errorf("reviewer router script is missing contract marker %q", want)
}
}
for _, forbidden := range []string{
"actions/checkout",
"['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']",
"PeterGuy326",
"core.setFailed",
} {
if strings.Contains(string(data), forbidden) {
t.Errorf("reviewer router must not contain %q", forbidden)
}
}
}
@@ -1,40 +0,0 @@
// 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 scripts_test
import (
"os/exec"
"path/filepath"
"testing"
)
// TestShortcutRealResultSelfTest wires scripts/shortcut_real_result_test.py into
// `go test ./...` so the upper/lower projection-audit rules (and the end-to-end
// record_real_shortcut_run.py integration) are enforced in CI, not manual-only.
func TestShortcutRealResultSelfTest(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("repo root: %v", err)
}
py, err := exec.LookPath("python3")
if err != nil {
t.Skipf("python3 not available: %v", err)
}
cmd := exec.Command(py, filepath.Join("scripts", "shortcut_real_result_test.py"))
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("shortcut_real_result_test.py failed: %v\n%s", err, out)
}
}